feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit bb7968b612
2381 files changed
+551494

No files matched your search

+424
View File
@@ -0,0 +1,424 @@
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/service/authz"
// Import oauth package to register providers via init()
_ "github.com/QuantumNous/new-api/oauth"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
)
func SetApiRouter(router *gin.Engine) {
apiRouter := router.Group("/api")
apiRouter.Use(middleware.RouteTag("api"))
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.AccessTokenAudit())
apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
apiRouter.Use(middleware.GlobalAPIRateLimit())
anonymousRequestBodyLimit := middleware.AnonymousRequestBodyLimit()
{
apiRouter.GET("/setup", controller.GetSetup)
apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup)
apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
apiRouter.GET("/notice", controller.GetNotice)
apiRouter.GET("/user-agreement", controller.GetUserAgreement)
apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy)
apiRouter.GET("/about", controller.GetAbout)
//apiRouter.GET("/midjourney", controller.GetMidjourney)
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing)
perfMetricsRoute := apiRouter.Group("/perf-metrics")
perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing"))
{
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
perfMetricsRoute.GET("", controller.GetPerfMetrics)
}
apiRouter.GET("/rankings", middleware.HeaderNavModuleAuth("rankings"), controller.GetRankings)
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.ResetPassword)
// OAuth routes - specific routes must come before :provider wildcard
apiRouter.POST("/oauth/state", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), anonymousRequestBodyLimit, controller.GenerateOAuthCode)
apiRouter.POST("/oauth/email/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("account-security"), middleware.EmailVerificationRateLimit(), middleware.DisableCache(), controller.EmailBindStart)
apiRouter.POST("/oauth/email/bind/resend", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("account-security"), middleware.EmailVerificationRateLimit(), middleware.DisableCache(), controller.EmailBindResend)
apiRouter.POST("/oauth/email/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("account-security"), middleware.DisableCache(), controller.EmailBind)
// WeChat uses its existing authorization-code service.
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.WeChatAuth)
apiRouter.POST("/oauth/wechat/bind", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth)
apiRouter.POST("/oauth/telegram/bind/start", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth)
apiRouter.GET("/oauth/telegram/bind/:flow_token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.TelegramLegacyAuth)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO, Telegram) - unified route
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), controller.HandleOAuth)
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
apiRouter.POST("/stripe/webhook", anonymousRequestBodyLimit, controller.StripeWebhook)
apiRouter.POST("/creem/webhook", anonymousRequestBodyLimit, controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", anonymousRequestBodyLimit, controller.WaffoWebhook)
// :env separates test vs prod URLs so the operator can register each
// in Pancake's matching webhook slot; handler enforces env match.
apiRouter.POST("/waffo-pancake/webhook/:env", anonymousRequestBodyLimit, controller.WaffoPancakeWebhook)
// Universal secure verification routes
apiRouter.GET("/verify/methods", middleware.UserAuth(), middleware.DisableCache(), controller.GetVerificationMethods)
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.UniversalVerify)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/auth/refresh", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RefreshAuth)
userRoute.POST("/auth/logout", middleware.SessionCookieOriginGuard(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.AuthLogout)
userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register)
userRoute.GET("/login/encryption-key", middleware.DisableCache(), controller.GetPasswordEncryptionKey)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.Verify2FALogin)
userRoute.POST("/login/verify", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.VerifyLogin)
userRoute.POST("/login/passkey/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.LoginPasskeyBegin)
userRoute.POST("/login/passkey/finish", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.LoginPasskeyFinish)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin)
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginFinish)
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
userRoute.POST("/epay/notify", anonymousRequestBodyLimit, controller.EpayNotify)
userRoute.GET("/epay/notify", controller.EpayNotify)
userRoute.GET("/groups", controller.GetUserGroups)
selfRoute := userRoute.Group("/")
selfRoute.Use(middleware.DisableCache(), middleware.UserAuth())
{
selfRoute.GET("/sessions", middleware.DisableCache(), controller.GetLoginSessions)
selfRoute.DELETE("/sessions/:sid", middleware.DisableCache(), controller.DeleteLoginSession)
selfRoute.POST("/sessions/revoke-others", middleware.DisableCache(), controller.RevokeOtherLoginSessions)
selfRoute.GET("/self/groups", controller.GetUserGroups)
selfRoute.GET("/self", controller.GetSelf)
selfRoute.GET("/models", controller.GetUserModels)
selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf)
selfRoute.DELETE("/self", middleware.DisableCache(), controller.DeleteSelf)
selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.GET("/token/status", middleware.DisableCache(), controller.GetAccessTokenStatus)
selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.DELETE("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.RevokeAccessToken)
selfRoute.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterFinish)
selfRoute.POST("/passkey/verify/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyVerifyBegin)
selfRoute.POST("/passkey/verify/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyVerifyFinish)
selfRoute.DELETE("/passkey", middleware.DisableCache(), controller.PasskeyDelete)
selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
selfRoute.GET("/topup/self", controller.GetUserTopUps)
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
selfRoute.POST("/amount", controller.RequestAmount)
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount)
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
selfRoute.POST("/aff_transfer", middleware.UserCriticalRateLimit("aff-transfer"), controller.TransferAffQuota)
selfRoute.PUT("/setting", controller.UpdateUserSetting)
// 2FA routes
selfRoute.GET("/2fa/status", controller.Get2FAStatus)
selfRoute.POST("/2fa/setup", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.Setup2FA)
selfRoute.POST("/2fa/enable", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.Enable2FA)
selfRoute.POST("/2fa/disable", middleware.DisableCache(), controller.Disable2FA)
selfRoute.POST("/2fa/backup_codes", middleware.DisableCache(), controller.RegenerateBackupCodes)
// Check-in routes
selfRoute.GET("/checkin", controller.GetCheckinStatus)
selfRoute.POST("/checkin", middleware.TurnstileCheck(), controller.DoCheckin)
// Custom OAuth bindings
selfRoute.GET("/oauth/bindings", controller.GetUserOAuthBindings)
selfRoute.DELETE("/oauth/bindings/:provider_id", controller.UnbindCustomOAuth)
}
adminRoute := userRoute.Group("/")
adminRoute.Use(middleware.AdminAuth())
{
adminRoute.GET("/", controller.GetAllUsers)
adminRoute.GET("/topup", controller.GetAllTopUps)
adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp)
adminRoute.GET("/search", controller.SearchUsers)
adminRoute.GET("/:id/oauth/bindings", controller.GetUserOAuthBindingsByAdmin)
adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin)
adminRoute.DELETE("/:id/bindings/:binding_type", controller.AdminClearUserBinding)
adminRoute.GET("/:id", controller.GetUser)
adminRoute.POST("/", controller.CreateUser)
adminRoute.POST("/manage", controller.ManageUser)
adminRoute.PUT("/", controller.UpdateUser)
adminRoute.DELETE("/:id", controller.DeleteUser)
adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey)
// Admin 2FA routes
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA)
}
}
// Subscription billing (plans, purchase, admin management)
subscriptionRoute := apiRouter.Group("/subscription")
subscriptionRoute.Use(middleware.UserAuth())
{
subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans)
subscriptionRoute.GET("/self", controller.GetSubscriptionSelf)
subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference)
subscriptionRoute.POST("/balance/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestBalancePay)
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
subscriptionRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWaffoPancakePay)
}
subscriptionAdminRoute := apiRouter.Group("/subscription/admin")
subscriptionAdminRoute.Use(middleware.AdminAuth())
{
subscriptionAdminRoute.GET("/plans", controller.AdminListSubscriptionPlans)
subscriptionAdminRoute.POST("/plans", controller.AdminCreateSubscriptionPlan)
subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan)
subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus)
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
subscriptionAdminRoute.POST("/plans/:id/subscriptions/reset", controller.AdminResetPlanSubscriptions)
// User subscription management (admin)
subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions)
subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription)
subscriptionAdminRoute.POST("/users/:id/subscriptions/reset", controller.AdminResetUserSubscriptionsByPlan)
subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription)
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
}
// Subscription payment callbacks (no auth)
apiRouter.POST("/subscription/epay/notify", anonymousRequestBodyLimit, controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
apiRouter.POST("/subscription/epay/return", anonymousRequestBodyLimit, controller.SubscriptionEpayReturn)
optionRoute := apiRouter.Group("/option")
optionRoute.Use(middleware.RootAuth())
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.PUT("/", controller.UpdateOption)
optionRoute.GET("/model_pricing", controller.GetModelPricingConfig)
optionRoute.PATCH("/model_pricing", controller.UpdateModelPricingConfig)
optionRoute.POST("/payment_compliance", controller.ConfirmPaymentCompliance)
optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats)
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
optionRoute.GET("/waffo-pancake/catalog", controller.ListWaffoPancakeCatalog)
optionRoute.POST("/waffo-pancake/pair", controller.CreateWaffoPancakePair)
optionRoute.POST("/waffo-pancake/save", controller.SaveWaffoPancake)
optionRoute.POST("/waffo-pancake/subscription-product", controller.CreateWaffoPancakeSubscriptionProduct)
optionRoute.GET("/waffo-pancake/subscription-product-options", controller.ListWaffoPancakeSubscriptionProductOptions)
}
// Custom OAuth provider management (root only)
customOAuthRoute := apiRouter.Group("/custom-oauth-provider")
customOAuthRoute.Use(middleware.RootAuth())
{
customOAuthRoute.POST("/discovery", controller.FetchCustomOAuthDiscovery)
customOAuthRoute.GET("/", controller.GetCustomOAuthProviders)
customOAuthRoute.GET("/:id", controller.GetCustomOAuthProvider)
customOAuthRoute.POST("/", controller.CreateCustomOAuthProvider)
customOAuthRoute.PUT("/:id", controller.UpdateCustomOAuthProvider)
customOAuthRoute.DELETE("/:id", controller.DeleteCustomOAuthProvider)
}
performanceRoute := apiRouter.Group("/performance")
performanceRoute.Use(middleware.RootAuth())
{
performanceRoute.GET("/stats", controller.GetPerformanceStats)
performanceRoute.DELETE("/disk_cache", controller.ClearDiskCache)
performanceRoute.POST("/reset_stats", controller.ResetPerformanceStats)
performanceRoute.POST("/gc", controller.ForceGC)
performanceRoute.GET("/logs", controller.GetLogFiles)
performanceRoute.DELETE("/logs", controller.CleanupLogFiles)
}
ratioSyncRoute := apiRouter.Group("/ratio_sync")
ratioSyncRoute.Use(middleware.RootAuth())
{
ratioSyncRoute.GET("/channels", controller.GetSyncableChannels)
ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios)
}
taskPluginRoute := apiRouter.Group("/plugin/task")
taskPluginRoute.Use(middleware.RootAuth())
{
taskPluginRoute.GET("", controller.ListTaskPlugins)
taskPluginRoute.POST("", controller.UploadTaskPlugin)
taskPluginRoute.PUT("", controller.UploadTaskPlugin)
taskPluginRoute.GET("/runtime/status", controller.GetTaskPluginRuntime)
taskPluginRoute.GET("/marketplace/sources", controller.GetTaskPluginMarketplaceSources)
taskPluginRoute.PUT("/marketplace/sources", controller.UpdateTaskPluginMarketplaceSources)
taskPluginRoute.GET("/:key", controller.GetTaskPlugin)
taskPluginRoute.GET("/:key/icon", controller.GetTaskPluginIcon)
taskPluginRoute.GET("/:key/versions", controller.GetTaskPluginVersions)
taskPluginRoute.POST("/:key/activate", controller.ActivateTaskPlugin)
taskPluginRoute.POST("/:key/status", controller.SetTaskPluginStatus)
taskPluginRoute.POST("/:key/dryrun", controller.DryRunTaskPlugin)
taskPluginRoute.DELETE("/:key/versions/:version", controller.DeleteTaskPluginVersion)
}
apiRouter.GET("/task_plugin_options", middleware.AdminAuth(), middleware.RequirePermission(authz.TaskPluginBind), controller.GetTaskPluginOptions)
registerChannelRoutes(apiRouter)
registerAuthzRoutes(apiRouter)
tokenRoute := apiRouter.Group("/token")
tokenRoute.Use(middleware.UserAuth())
tokenRoute.Use(middleware.TokenOperationAudit())
{
tokenRoute.GET("/", controller.GetAllTokens)
tokenRoute.GET("/search", middleware.SearchRateLimit(), controller.SearchTokens)
tokenRoute.GET("/auto-groups", controller.GetTokenAutoGroups)
tokenRoute.GET("/:id", controller.GetToken)
tokenRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKey)
tokenRoute.POST("/", controller.AddToken)
tokenRoute.PUT("/", controller.UpdateToken)
tokenRoute.DELETE("/:id", controller.DeleteToken)
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
}
usageRoute := apiRouter.Group("/usage")
usageRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
{
tokenUsageRoute := usageRoute.Group("/token")
tokenUsageRoute.Use(middleware.TokenAuthReadOnly())
{
tokenUsageRoute.GET("/", controller.GetTokenUsage)
}
}
redemptionRoute := apiRouter.Group("/redemption")
redemptionRoute.Use(middleware.AdminAuth())
{
redemptionRoute.GET("/", controller.GetAllRedemptions)
redemptionRoute.GET("/search", controller.SearchRedemptions)
redemptionRoute.GET("/:id", controller.GetRedemption)
redemptionRoute.POST("/", controller.AddRedemption)
redemptionRoute.POST("/batch", controller.DeleteRedemptionBatch)
redemptionRoute.PUT("/", controller.UpdateRedemption)
redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption)
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
}
apiRouter.GET("/audit", middleware.DisableCache(), middleware.AdminAuth(), middleware.RequirePermission(authz.AuditRead), controller.GetAuditLogs)
apiRouter.GET("/audit/self", middleware.DisableCache(), middleware.UserAuth(), controller.GetAuditLogs)
logRoute := apiRouter.Group("/log")
logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
logRoute.GET("/self/search", middleware.UserAuth(), middleware.SearchRateLimit(), controller.SearchUserLogs)
systemTaskRoute := apiRouter.Group("/system-task")
systemTaskRoute.Use(middleware.RootAuth())
{
systemTaskRoute.POST("/log-cleanup", controller.CreateLogCleanupSystemTask)
systemTaskRoute.GET("/list", controller.ListSystemTasks)
systemTaskRoute.GET("/current", controller.GetCurrentSystemTask)
systemTaskRoute.GET("/:task_id", controller.GetSystemTask)
}
systemInfoRoute := apiRouter.Group("/system-info")
systemInfoRoute.Use(middleware.RootAuth())
{
systemInfoRoute.GET("/instances", controller.ListSystemInstances)
systemInfoRoute.DELETE("/stale-instances", controller.DeleteStaleSystemInstances)
systemInfoRoute.DELETE("/instances/:node_name", controller.DeleteStaleSystemInstance)
}
dataRoute := apiRouter.Group("/data")
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
dataRoute.GET("/flow", middleware.AdminAuth(), controller.GetAllFlowQuotaDates)
dataRoute.GET("/flow/self", middleware.UserAuth(), controller.GetUserFlowQuotaDates)
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
{
logRoute.GET("/token", middleware.TokenAuthReadOnly(), controller.GetLogByKey)
}
groupRoute := apiRouter.Group("/group")
groupRoute.Use(middleware.AdminAuth())
{
groupRoute.GET("/", controller.GetGroups)
}
prefillGroupRoute := apiRouter.Group("/prefill_group")
prefillGroupRoute.Use(middleware.AdminAuth())
{
prefillGroupRoute.GET("/", controller.GetPrefillGroups)
prefillGroupRoute.POST("/", controller.CreatePrefillGroup)
prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup)
prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup)
}
mjRoute := apiRouter.Group("/mj")
mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)
taskRoute := apiRouter.Group("/task")
{
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
taskRoute.GET("", middleware.AdminAuth(), controller.GetAllTask)
taskRoute.GET("/:task_id/artifacts", middleware.UserAuth(), controller.GetDashboardTaskArtifacts)
}
vendorRoute := apiRouter.Group("/vendors")
vendorRoute.Use(middleware.AdminAuth())
{
vendorRoute.POST("/operations/preview", controller.PreviewVendorOperation)
vendorRoute.POST("/operations", controller.ApplyVendorOperation)
vendorRoute.GET("/", controller.GetAllVendors)
vendorRoute.GET("/search", controller.SearchVendors)
vendorRoute.GET("/:id", controller.GetVendorMeta)
vendorRoute.POST("/", controller.CreateVendorMeta)
vendorRoute.PUT("/", controller.UpdateVendorMeta)
vendorRoute.DELETE("/:id", controller.DeleteVendorMeta)
}
modelsRoute := apiRouter.Group("/models")
modelsRoute.Use(middleware.AdminAuth())
{
modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview)
modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels)
modelsRoute.POST("/delete", controller.BatchDeleteModelMeta)
modelsRoute.GET("/missing", controller.GetMissingModels)
modelsRoute.GET("/", controller.GetAllModelsMeta)
modelsRoute.GET("/search", controller.SearchModelsMeta)
modelsRoute.GET("/:id", controller.GetModelMeta)
modelsRoute.POST("/", controller.CreateModelMeta)
modelsRoute.PUT("/", controller.UpdateModelMeta)
modelsRoute.DELETE("/:id", controller.DeleteModelMeta)
}
// Deployments (model deployment management)
deploymentsRoute := apiRouter.Group("/deployments")
deploymentsRoute.Use(middleware.AdminAuth())
{
deploymentsRoute.GET("/settings", controller.GetModelDeploymentSettings)
deploymentsRoute.POST("/settings/test-connection", controller.TestIoNetConnection)
deploymentsRoute.GET("/", controller.GetAllDeployments)
deploymentsRoute.GET("/search", controller.SearchDeployments)
deploymentsRoute.POST("/test-connection", controller.TestIoNetConnection)
deploymentsRoute.GET("/hardware-types", controller.GetHardwareTypes)
deploymentsRoute.GET("/locations", controller.GetLocations)
deploymentsRoute.GET("/available-replicas", controller.GetAvailableReplicas)
deploymentsRoute.POST("/price-estimation", controller.GetPriceEstimation)
deploymentsRoute.GET("/check-name", controller.CheckClusterNameAvailability)
deploymentsRoute.POST("/", controller.CreateDeployment)
deploymentsRoute.GET("/:id", controller.GetDeployment)
deploymentsRoute.GET("/:id/logs", controller.GetDeploymentLogs)
deploymentsRoute.GET("/:id/containers", controller.ListDeploymentContainers)
deploymentsRoute.GET("/:id/containers/:container_id", controller.GetContainerDetails)
deploymentsRoute.PUT("/:id", controller.UpdateDeployment)
deploymentsRoute.PUT("/:id/name", controller.UpdateDeploymentName)
deploymentsRoute.POST("/:id/extend", controller.ExtendDeployment)
deploymentsRoute.DELETE("/:id", controller.DeleteDeployment)
}
}
}
+19
View File
@@ -0,0 +1,19 @@
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-gonic/gin"
)
// registerAuthzRoutes mounts the authorization API under its own /authz
// namespace. GET /authz/catalog returns the permission schema (resources,
// actions, and role baselines) used by the client permission editor.
func registerAuthzRoutes(apiRouter *gin.RouterGroup) {
authzRoute := apiRouter.Group("/authz")
authzRoute.Use(middleware.AdminAuth())
{
authzRoute.GET("/catalog", controller.GetPermissionCatalog)
}
}
+79
View File
@@ -0,0 +1,79 @@
package router
import (
"net/http"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
)
type permissionRoute struct {
method string
path string
permission authz.Permission
handler gin.HandlerFunc
}
func registerChannelRoutes(apiRouter *gin.RouterGroup) {
channelRoute := apiRouter.Group("/channel")
channelRoute.Use(middleware.AdminAuth())
channelRoute.POST("/:id/key",
middleware.RootAuth(),
middleware.CriticalRateLimit(),
middleware.DisableCache(),
middleware.SecureVerificationRequired(),
controller.GetChannelKey,
)
for _, route := range channelPermissionRoutes {
channelRoute.Handle(route.method, route.path,
middleware.RequirePermission(route.permission),
route.handler,
)
}
}
var channelPermissionRoutes = []permissionRoute{
{method: http.MethodGet, path: "/", permission: authz.ChannelRead, handler: controller.GetAllChannels},
{method: http.MethodGet, path: "/search", permission: authz.ChannelRead, handler: controller.SearchChannels},
{method: http.MethodGet, path: "/models", permission: authz.ChannelRead, handler: controller.ChannelListModels},
{method: http.MethodGet, path: "/models_enabled", permission: authz.ChannelRead, handler: controller.EnabledListModels},
{method: http.MethodGet, path: "/ops", permission: authz.ChannelRead, handler: controller.GetChannelOps},
{method: http.MethodGet, path: "/:id", permission: authz.ChannelRead, handler: controller.GetChannel},
{method: http.MethodGet, path: "/test", permission: authz.ChannelOperate, handler: controller.TestAllChannels},
{method: http.MethodGet, path: "/test/:id", permission: authz.ChannelOperate, handler: controller.TestChannel},
{method: http.MethodGet, path: "/update_balance", permission: authz.ChannelOperate, handler: controller.UpdateAllChannelsBalance},
{method: http.MethodGet, path: "/update_balance/:id", permission: authz.ChannelOperate, handler: controller.UpdateChannelBalance},
{method: http.MethodPost, path: "/", permission: authz.ChannelSensitiveWrite, handler: controller.AddChannel},
{method: http.MethodPut, path: "/", permission: authz.ChannelWrite, handler: controller.UpdateChannel},
{method: http.MethodPost, path: "/status/batch", permission: authz.ChannelOperate, handler: controller.BatchUpdateChannelStatus},
{method: http.MethodPost, path: "/:id/status", permission: authz.ChannelOperate, handler: controller.UpdateChannelStatus},
{method: http.MethodDelete, path: "/disabled", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteDisabledChannel},
{method: http.MethodPost, path: "/tag/disabled", permission: authz.ChannelOperate, handler: controller.DisableTagChannels},
{method: http.MethodPost, path: "/tag/enabled", permission: authz.ChannelOperate, handler: controller.EnableTagChannels},
{method: http.MethodPut, path: "/tag", permission: authz.ChannelWrite, handler: controller.EditTagChannels},
{method: http.MethodDelete, path: "/:id", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannel},
{method: http.MethodPost, path: "/batch", permission: authz.ChannelSensitiveWrite, handler: controller.DeleteChannelBatch},
{method: http.MethodPost, path: "/fix", permission: authz.ChannelOperate, handler: controller.FixChannelsAbilities},
{method: http.MethodGet, path: "/fetch_models/:id", permission: authz.ChannelOperate, handler: controller.FetchUpstreamModels},
{method: http.MethodPost, path: "/fetch_models", permission: authz.ChannelSensitiveWrite, handler: controller.FetchModels},
{method: http.MethodPost, path: "/:id/codex/refresh", permission: authz.ChannelSensitiveWrite, handler: controller.RefreshCodexChannelCredential},
{method: http.MethodGet, path: "/:id/codex/usage", permission: authz.ChannelRead, handler: controller.GetCodexChannelUsage},
{method: http.MethodGet, path: "/:id/codex/usage/reset-credits", permission: authz.ChannelRead, handler: controller.GetCodexChannelRateLimitResetCredits},
{method: http.MethodPost, path: "/:id/codex/usage/reset", permission: authz.ChannelOperate, handler: controller.ResetCodexChannelUsage},
{method: http.MethodPost, path: "/ollama/pull", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModel},
{method: http.MethodPost, path: "/ollama/pull/stream", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaPullModelStream},
{method: http.MethodDelete, path: "/ollama/delete", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaDeleteModel},
{method: http.MethodGet, path: "/ollama/version/:id", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaVersion},
{method: http.MethodPost, path: "/batch/tag", permission: authz.ChannelWrite, handler: controller.BatchSetChannelTag},
{method: http.MethodGet, path: "/tag/models", permission: authz.ChannelRead, handler: controller.GetTagModels},
{method: http.MethodPost, path: "/copy/:id", permission: authz.ChannelSensitiveWrite, handler: controller.CopyChannel},
{method: http.MethodPost, path: "/multi_key/manage", permission: authz.ChannelOperate, handler: controller.ManageMultiKeys},
{method: http.MethodPost, path: "/upstream_updates/apply", permission: authz.ChannelWrite, handler: controller.ApplyChannelUpstreamModelUpdates},
{method: http.MethodPost, path: "/upstream_updates/apply_all", permission: authz.ChannelWrite, handler: controller.ApplyAllChannelUpstreamModelUpdates},
{method: http.MethodPost, path: "/upstream_updates/detect", permission: authz.ChannelOperate, handler: controller.DetectChannelUpstreamModelUpdates},
{method: http.MethodPost, path: "/upstream_updates/detect_all", permission: authz.ChannelOperate, handler: controller.DetectAllChannelUpstreamModelUpdates},
}
+50
View File
@@ -0,0 +1,50 @@
package router
import (
"net/http"
"reflect"
"testing"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestChannelStatusRoutesUseOperatePermission(t *testing.T) {
assertChannelRoutePermission(t, http.MethodPost, "/:id/status", authz.ChannelOperate, controller.UpdateChannelStatus)
assertChannelRoutePermission(t, http.MethodPost, "/status/batch", authz.ChannelOperate, controller.BatchUpdateChannelStatus)
assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel)
}
func TestChannelDeleteRoutesUseSensitiveWritePermission(t *testing.T) {
assertChannelRoutePermission(t, http.MethodDelete, "/:id", authz.ChannelSensitiveWrite, controller.DeleteChannel)
assertChannelRoutePermission(t, http.MethodPost, "/batch", authz.ChannelSensitiveWrite, controller.DeleteChannelBatch)
assertChannelRoutePermission(t, http.MethodDelete, "/disabled", authz.ChannelSensitiveWrite, controller.DeleteDisabledChannel)
assertChannelRoutePermission(t, http.MethodPut, "/", authz.ChannelWrite, controller.UpdateChannel)
assertChannelRoutePermission(t, http.MethodPut, "/tag", authz.ChannelWrite, controller.EditTagChannels)
assertChannelRoutePermission(t, http.MethodPost, "/batch/tag", authz.ChannelWrite, controller.BatchSetChannelTag)
}
func TestChannelStatusRoutesRegisterWithoutConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
api := engine.Group("/api")
require.NotPanics(t, func() {
registerChannelRoutes(api)
})
}
func assertChannelRoutePermission(t *testing.T, method string, path string, permission authz.Permission, handler any) {
t.Helper()
for _, route := range channelPermissionRoutes {
if route.method == method && route.path == path {
assert.Equal(t, permission, route.permission)
assert.Equal(t, reflect.ValueOf(handler).Pointer(), reflect.ValueOf(route.handler).Pointer())
return
}
}
t.Fatalf("route %s %s not found", method, path)
}
+23
View File
@@ -0,0 +1,23 @@
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
)
func SetDashboardRouter(router *gin.Engine) {
apiRouter := router.Group("/")
apiRouter.Use(middleware.RouteTag("old_api"))
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.GlobalAPIRateLimit())
apiRouter.Use(middleware.CORS())
apiRouter.Use(middleware.TokenAuth())
{
apiRouter.GET("/dashboard/billing/subscription", controller.GetSubscription)
apiRouter.GET("/v1/dashboard/billing/subscription", controller.GetSubscription)
apiRouter.GET("/dashboard/billing/usage", controller.GetUsage)
apiRouter.GET("/v1/dashboard/billing/usage", controller.GetUsage)
}
}
+41
View File
@@ -0,0 +1,41 @@
package router
import (
"fmt"
"net/http"
"os"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-gonic/gin"
)
func SetRouter(router *gin.Engine, assets WebAssets) {
SetApiRouter(router)
SetDashboardRouter(router)
SetRelayRouter(router)
SetTaskPluginProtocolRouter(router)
SetVideoRouter(router)
SetTaskRouter(router)
pluginDispatcher := SetPluginRouter(router)
frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
if common.IsMasterNode && frontendBaseUrl != "" {
frontendBaseUrl = ""
common.SysLog("FRONTEND_BASE_URL is ignored on master node")
}
if frontendBaseUrl == "" {
SetWebRouter(router, assets, pluginDispatcher)
} else {
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
router.NoRoute(
pluginDispatcher,
middleware.RouteTag("web"),
middleware.AccessTokenAudit(),
func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
},
)
}
}
+585
View File
@@ -0,0 +1,585 @@
package router
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-gonic/gin"
)
type pluginDispatchStateKey struct{}
type pluginDispatchState struct {
generation *jsplugin.RoutingGeneration
hit atomic.Bool
writer *gatedResponseWriter
requestID string
language string
}
func (s *pluginDispatchState) markHit() {
s.hit.Store(true)
s.writer.activate()
}
type pluginRouteHandlers func(*jsplugin.RoutingGeneration, jsplugin.RouteBinding) []gin.HandlerFunc
type pluginGenerationBuilder struct {
staticRoutes []gin.RouteInfo
trustedProxies []string
routeHandlers pluginRouteHandlers
registerRoute func(*gin.Engine, jsplugin.RouteBinding, []gin.HandlerFunc)
configure func(*gin.Engine) error
}
type pluginRouteDispatcher struct {
registry *jsplugin.Registry
}
func SetPluginRouter(outer *gin.Engine) gin.HandlerFunc {
trustedProxies, _, err := common.ResolveTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
dispatcher := &pluginRouteDispatcher{registry: jsplugin.DefaultRegistry}
if err != nil {
common.SysError("configure plugin router trusted proxies: " + err.Error())
return dispatcher.dispatch
}
builder := newPluginGenerationBuilder(outer.Routes(), trustedProxies, productionPluginRouteHandlers)
if err = jsplugin.DefaultRegistry.SetGenerationPreparer(builder.prepare); err != nil {
common.SysError("build initial plugin router: " + err.Error())
}
return dispatcher.dispatch
}
func newPluginGenerationBuilder(staticRoutes []gin.RouteInfo, trustedProxies []string, handlers pluginRouteHandlers) *pluginGenerationBuilder {
builder := &pluginGenerationBuilder{
staticRoutes: append([]gin.RouteInfo(nil), staticRoutes...),
trustedProxies: append([]string(nil), trustedProxies...),
routeHandlers: handlers,
}
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, routeHandlers []gin.HandlerFunc) {
engine.Handle(binding.Route.Method, binding.Route.Path, routeHandlers...)
}
builder.configure = func(engine *gin.Engine) error {
return common.ConfigureTrustedProxies(engine, builder.trustedProxies)
}
return builder
}
func productionPluginRouteHandlers(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
pinRoute := func(c *gin.Context) {
pinnedGeneration := generation
if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
pinnedGeneration = state.generation
}
c.Set(jsplugin.ContextKeyPinnedPlugin, jsplugin.PinnedPlugin{
Generation: pinnedGeneration,
Plugin: binding.Plugin,
})
c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{
Generation: pinnedGeneration,
Plugin: binding.Plugin,
Route: binding.Route,
})
logger.LogDebug(
c,
"task_plugin subsystem=router event=route_matched generation=%d plugin=%q version=%q method=%q route_type=%q",
pinnedGeneration.Number,
binding.Plugin.Meta.Key,
binding.Plugin.Meta.Version,
binding.Route.Method,
binding.Route.Type,
)
c.Next()
logger.LogDebug(
c,
"task_plugin subsystem=router event=route_complete generation=%d plugin=%q method=%q status=%d",
pinnedGeneration.Number,
binding.Plugin.Meta.Key,
binding.Route.Method,
c.Writer.Status(),
)
}
return []gin.HandlerFunc{
pinRoute,
middleware.TokenAuth(),
middleware.SystemPerformanceCheck(),
middleware.ModelRequestRateLimit(),
middleware.PrepareTaskPluginRoute(),
middleware.Distribute(),
controller.RelayTask,
}
}
func (b *pluginGenerationBuilder) prepare(candidate, current *jsplugin.RoutingGeneration) (jsplugin.PreparedRoutingGeneration, error) {
blocked := make(map[*jsplugin.LoadedPlugin]string)
for {
accepted, routingErrors := b.admitPlugins(candidate, current, blocked)
plugins := sortedPlugins(accepted)
filtered, err := candidate.RebuildWithPlugins(plugins)
if err != nil {
return jsplugin.PreparedRoutingGeneration{}, err
}
engine, offender, buildErr := b.buildInnerEngine(filtered)
if buildErr == nil {
return jsplugin.PreparedRoutingGeneration{
Generation: filtered.WithRuntime(engine),
Errors: routingErrors,
}, nil
}
if offender == "" {
return jsplugin.PreparedRoutingGeneration{}, buildErr
}
failedPlugin := accepted[offender]
if failedPlugin == nil {
return jsplugin.PreparedRoutingGeneration{}, fmt.Errorf("public route rebuild attributed failure to absent plugin %q: %w", offender, buildErr)
}
blocked[failedPlugin] = fmt.Sprintf("plugin %s rejected while rebuilding public routes: %v", offender, buildErr)
}
}
func (b *pluginGenerationBuilder) admitPlugins(
candidate, current *jsplugin.RoutingGeneration,
blocked map[*jsplugin.LoadedPlugin]string,
) (map[string]*jsplugin.LoadedPlugin, map[string]string) {
accepted := make(map[string]*jsplugin.LoadedPlugin)
currentByKey := make(map[string]*jsplugin.LoadedPlugin)
if current != nil && current.RuntimeHandler() != nil {
for _, plugin := range current.Plugins() {
currentByKey[plugin.Meta.Key] = plugin
}
}
unchangedKeys := make([]string, 0)
changedKeys := make([]string, 0)
newKeys := make([]string, 0)
for _, plugin := range candidate.Plugins() {
incumbent, existed := currentByKey[plugin.Meta.Key]
switch {
case existed && incumbent == plugin:
unchangedKeys = append(unchangedKeys, plugin.Meta.Key)
case existed:
changedKeys = append(changedKeys, plugin.Meta.Key)
default:
newKeys = append(newKeys, plugin.Meta.Key)
}
}
sort.Strings(unchangedKeys)
sort.Strings(changedKeys)
sort.Strings(newKeys)
routingErrors := make(map[string]string)
orderedKeys := append(unchangedKeys, changedKeys...)
orderedKeys = append(orderedKeys, newKeys...)
rejectedKeys := make([]string, 0)
for _, key := range orderedKeys {
plugin, _ := candidate.Get(key)
if blockedError := blocked[plugin]; blockedError != "" {
routingErrors[key] = blockedError
} else if err := b.validatePlugin(plugin, accepted); err != nil {
routingErrors[key] = fmt.Sprintf("plugin %s rejected from public routes: %v", key, err)
} else {
accepted[key] = plugin
continue
}
rejectedKeys = append(rejectedKeys, key)
}
for _, key := range rejectedKeys {
plugin, _ := candidate.Get(key)
incumbent := currentByKey[key]
if incumbent == nil || incumbent == plugin || !candidate.RetainsIncumbent(key) {
continue
}
if blockedError := blocked[incumbent]; blockedError != "" {
routingErrors[key] = blockedError
continue
}
if err := b.validatePlugin(incumbent, accepted); err == nil {
accepted[key] = incumbent
}
}
return accepted, routingErrors
}
func (b *pluginGenerationBuilder) validatePlugin(plugin *jsplugin.LoadedPlugin, accepted map[string]*jsplugin.LoadedPlugin) error {
for _, route := range plugin.Meta.Routes {
for _, staticRoute := range b.staticRoutes {
if routeIntersectsStaticRoute(route.Path, staticRoute.Path) {
return fmt.Errorf("route %s %s intersects static route %s %s", route.Method, route.Path, staticRoute.Method, staticRoute.Path)
}
}
}
for index, left := range plugin.Meta.Routes {
for _, right := range plugin.Meta.Routes[index+1:] {
if left.Method != right.Method {
continue
}
if routePatternsIntersect(left.Path, right.Path) {
return fmt.Errorf("routes %s %s and %s overlap", left.Method, left.Path, right.Path)
}
if !routesGinCompatible(left.Path, right.Path) {
return fmt.Errorf("routes %s %s and %s use incompatible wildcard names", left.Method, left.Path, right.Path)
}
}
}
for _, other := range accepted {
if other.Meta.Key == plugin.Meta.Key {
continue
}
for _, route := range plugin.Meta.Routes {
for _, otherRoute := range other.Meta.Routes {
if routePatternsIntersect(route.Path, otherRoute.Path) {
return fmt.Errorf("route %s %s overlaps plugin %s route %s %s", route.Method, route.Path, other.Meta.Key, otherRoute.Method, otherRoute.Path)
}
if route.Method == otherRoute.Method && !routesGinCompatible(route.Path, otherRoute.Path) {
return fmt.Errorf("route %s %s is structurally incompatible with plugin %s route %s", route.Method, route.Path, other.Meta.Key, otherRoute.Path)
}
}
}
}
return nil
}
func (b *pluginGenerationBuilder) buildInnerEngine(generation *jsplugin.RoutingGeneration) (engine *gin.Engine, offender string, err error) {
currentPlugin := ""
defer func() {
if recovered := recover(); recovered != nil {
offender = currentPlugin
err = fmt.Errorf("inner Gin registration panic: %v", recovered)
engine = nil
}
}()
engine = gin.New()
engine.RedirectTrailingSlash = false
engine.HandleMethodNotAllowed = true
if err = b.configure(engine); err != nil {
return nil, "", err
}
engine.Use(importPluginDispatchState())
engine.Use(pluginRouteRecovery())
engine.Use(middleware.BodyStorageCleanup())
engine.NoMethod(func(c *gin.Context) {
markPluginRouteHit(c)
generation := uint64(0)
if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
generation = state.generation.Number
}
logger.LogDebug(
c,
"task_plugin subsystem=router event=method_not_allowed generation=%d request_method=%q status=%d",
generation,
c.Request.Method,
http.StatusMethodNotAllowed,
)
c.AbortWithStatus(http.StatusMethodNotAllowed)
})
for _, binding := range generation.Routes() {
currentPlugin = binding.Plugin.Meta.Key
b.registerRoute(engine, binding, b.routeHandlers(generation, binding))
}
currentPlugin = ""
return engine, "", nil
}
func importPluginDispatchState() gin.HandlerFunc {
return func(c *gin.Context) {
state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState)
if state != nil {
if c.FullPath() != "" {
state.markHit()
}
if state.requestID != "" {
c.Set(common.RequestIdKey, state.requestID)
}
if state.language != "" {
c.Set(string(constant.ContextKeyLanguage), state.language)
}
}
c.Set(middleware.RouteTagKey, "relay")
c.Next()
}
}
func markPluginRouteHit(c *gin.Context) {
state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState)
if state != nil {
state.markHit()
}
}
func pluginRouteRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if recover() == nil {
return
}
common.SysError("panic recovered in plugin route")
if pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedRoute); exists {
if pinned, ok := pinnedValue.(jsplugin.PinnedRoute); ok && pinned.Plugin != nil && pinned.Generation != nil {
logger.LogDebug(
c,
"task_plugin subsystem=router event=panic_recovered generation=%d plugin=%q method=%q",
pinned.Generation.Number,
pinned.Plugin.Meta.Key,
pinned.Route.Method,
)
}
}
c.Abort()
if !c.Writer.Written() {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
"message": "internal plugin route error",
"type": "plugin_route_error",
},
})
}
}()
c.Next()
}
}
func (d *pluginRouteDispatcher) dispatch(c *gin.Context) {
generation := d.registry.Generation()
if generation == nil || generation.RuntimeHandler() == nil {
c.Next()
return
}
previousTag, hadPreviousTag := c.Get(middleware.RouteTagKey)
c.Set(middleware.RouteTagKey, "relay")
originalContext := c.Request.Context()
state := &pluginDispatchState{
generation: generation,
requestID: c.GetString(common.RequestIdKey),
language: c.GetString(string(constant.ContextKeyLanguage)),
}
gatedWriter := newGatedResponseWriter(c.Writer)
state.writer = gatedWriter
c.Request = c.Request.WithContext(context.WithValue(originalContext, pluginDispatchStateKey{}, state))
generation.RuntimeHandler().ServeHTTP(gatedWriter, c.Request)
if state.hit.Load() {
if !c.Writer.Written() {
c.Writer.WriteHeaderNow()
}
c.Abort()
return
}
c.Request = c.Request.WithContext(originalContext)
if hadPreviousTag {
c.Set(middleware.RouteTagKey, previousTag)
} else {
delete(c.Keys, middleware.RouteTagKey)
}
c.Next()
}
type gatedResponseWriter struct {
underlying gin.ResponseWriter
privateHeader http.Header
active atomic.Bool
activateOnce sync.Once
pendingStatus int
}
func newGatedResponseWriter(underlying gin.ResponseWriter) *gatedResponseWriter {
return &gatedResponseWriter{
underlying: underlying,
privateHeader: underlying.Header().Clone(),
}
}
func (w *gatedResponseWriter) activate() {
w.activateOnce.Do(func() {
target := w.underlying.Header()
for key := range target {
target.Del(key)
}
for key, values := range w.privateHeader {
target[key] = append([]string(nil), values...)
}
w.active.Store(true)
if w.pendingStatus != 0 {
w.underlying.WriteHeader(w.pendingStatus)
}
})
}
func (w *gatedResponseWriter) Header() http.Header {
if w.active.Load() {
return w.underlying.Header()
}
return w.privateHeader
}
func (w *gatedResponseWriter) WriteHeader(statusCode int) {
if w.active.Load() {
w.underlying.WriteHeader(statusCode)
return
}
if w.pendingStatus == 0 {
w.pendingStatus = statusCode
}
}
func (w *gatedResponseWriter) Write(data []byte) (int, error) {
if !w.active.Load() {
return len(data), nil
}
return w.underlying.Write(data)
}
func (w *gatedResponseWriter) Flush() {
if w.active.Load() {
w.underlying.Flush()
}
}
func (w *gatedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if !w.active.Load() {
return nil, nil, errors.New("cannot hijack an unmatched plugin route")
}
return w.underlying.Hijack()
}
func (w *gatedResponseWriter) CloseNotify() <-chan bool {
if w.active.Load() {
return w.underlying.CloseNotify()
}
never := make(chan bool)
return never
}
func (w *gatedResponseWriter) Push(target string, options *http.PushOptions) error {
if !w.active.Load() {
return http.ErrNotSupported
}
pusher := w.underlying.Pusher()
if pusher == nil {
return http.ErrNotSupported
}
return pusher.Push(target, options)
}
func sortedPluginKeys(plugins map[string]*jsplugin.LoadedPlugin) []string {
keys := make([]string, 0, len(plugins))
for key := range plugins {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedPlugins(plugins map[string]*jsplugin.LoadedPlugin) []*jsplugin.LoadedPlugin {
keys := sortedPluginKeys(plugins)
sorted := make([]*jsplugin.LoadedPlugin, 0, len(keys))
for _, key := range keys {
sorted = append(sorted, plugins[key])
}
return sorted
}
type routePatternSegment struct {
value string
dynamic bool
catchAll bool
}
func parseRoutePattern(routePath string) []routePatternSegment {
parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
segments := make([]routePatternSegment, 0, len(parts))
for _, part := range parts {
segments = append(segments, routePatternSegment{
value: part,
dynamic: strings.HasPrefix(part, ":") || strings.HasPrefix(part, "*"),
catchAll: strings.HasPrefix(part, "*"),
})
}
return segments
}
func routePatternsIntersect(leftPath, rightPath string) bool {
left := parseRoutePattern(leftPath)
right := parseRoutePattern(rightPath)
for index := 0; ; index++ {
leftDone := index >= len(left)
rightDone := index >= len(right)
if leftDone || rightDone {
return leftDone && rightDone
}
if left[index].catchAll || right[index].catchAll {
return true
}
if !left[index].dynamic && !right[index].dynamic && left[index].value != right[index].value {
return false
}
if (left[index].dynamic && right[index].value == "") || (right[index].dynamic && left[index].value == "") {
return false
}
}
}
func routesGinCompatible(leftPath, rightPath string) bool {
left := parseRoutePattern(leftPath)
right := parseRoutePattern(rightPath)
limit := min(len(right), len(left))
for index := range limit {
leftSegment := left[index]
rightSegment := right[index]
if !leftSegment.dynamic && !rightSegment.dynamic {
if leftSegment.value != rightSegment.value {
return true
}
continue
}
if leftSegment.dynamic && rightSegment.dynamic {
if leftSegment.value != rightSegment.value {
return false
}
continue
}
return true
}
return true
}
func routeIntersectsStaticRoute(pluginPath, staticPath string) bool {
if routePatternsIntersect(pluginPath, staticPath) {
return true
}
if catchAllIndex := strings.LastIndex(staticPath, "/*"); catchAllIndex >= 0 && catchAllIndex+2 < len(staticPath) {
prefix := staticPath[:catchAllIndex]
if routePatternsIntersect(pluginPath, prefix) || routePatternsIntersect(pluginPath, prefix+"/") {
return true
}
return false
}
if staticPath == "/" {
return false
}
alternate := strings.TrimSuffix(staticPath, "/")
if alternate == staticPath {
alternate += "/"
}
return routePatternsIntersect(pluginPath, alternate)
}
+917
View File
@@ -0,0 +1,917 @@
package router
import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestPluginDispatcherMissFallsThroughWithoutLeakingInnerResponse(t *testing.T) {
outer, registry := newPluginRouterTest(t, nil, nil)
dispatcher := (&pluginRouteDispatcher{registry: registry}).dispatch
outer.NoRoute(
dispatcher,
func(c *gin.Context) {
assert.Equal(t, "before", c.GetString(middleware.RouteTagKey))
c.Header("X-Fallback", "true")
c.String(http.StatusOK, "spa")
},
)
recorder := performPluginRequest(outer, http.MethodGet, "/not-owned")
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "spa", recorder.Body.String())
assert.Equal(t, "true", recorder.Header().Get("X-Fallback"))
assert.Empty(t, recorder.Header().Get("Location"))
}
func TestPluginDebugLogsOnlyOwnedRoutes(t *testing.T) {
previousDebug := common.DebugEnabled
common.DebugEnabled = true
t.Cleanup(func() { common.DebugEnabled = previousDebug })
var output bytes.Buffer
common.LogWriterMu.Lock()
previousWriter := gin.DefaultErrorWriter
gin.DefaultErrorWriter = &output
common.LogWriterMu.Unlock()
t.Cleanup(func() {
common.LogWriterMu.Lock()
gin.DefaultErrorWriter = previousWriter
common.LogWriterMu.Unlock()
})
plugin := compileRouterPlugin(t, "debug-owner", "1.0.0", `[
{method: "GET", path: "/vendor/debug", type: "dynamic"}
]`)
handlers := func(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
production := productionPluginRouteHandlers(generation, binding)
return []gin.HandlerFunc{
production[0],
func(c *gin.Context) { c.Status(http.StatusNoContent) },
}
}
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
output.Reset()
owned := performPluginRequest(outer, http.MethodGet, "/vendor/debug")
require.Equal(t, http.StatusNoContent, owned.Code)
logOutput := output.String()
assert.Contains(t, logOutput, "request-phase-two")
assert.Contains(t, logOutput, "task_plugin subsystem=router event=route_matched")
assert.Contains(t, logOutput, `plugin="debug-owner"`)
assert.NotContains(t, logOutput, "/vendor/debug")
assert.Contains(t, logOutput, "event=route_complete")
output.Reset()
miss := performPluginRequest(outer, http.MethodGet, "/not-owned")
require.Equal(t, http.StatusOK, miss.Code)
assert.Equal(t, "fallback", miss.Body.String())
assert.NotContains(t, output.String(), "task_plugin")
}
func TestPluginAuthoredHeaderOnly404PassesThrough(t *testing.T) {
plugin := compileRouterPlugin(t, "authored-404", "1.0.0", `[
{method: "GET", path: "/vendor/missing", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.Header("X-Plugin", "authored")
c.Status(http.StatusNotFound)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodGet, "/vendor/missing")
assert.Equal(t, http.StatusNotFound, recorder.Code)
assert.Empty(t, recorder.Body.String())
assert.Equal(t, "authored", recorder.Header().Get("X-Plugin"))
}
func TestPluginOwnedPathMethodMismatchReturns405(t *testing.T) {
plugin := compileRouterPlugin(t, "method-owner", "1.0.0", `[
{method: "GET", path: "/vendor/jobs/:task_id", type: "query", render: "native"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodPost, "/vendor/jobs/task-1")
assert.Equal(t, http.StatusMethodNotAllowed, recorder.Code)
assert.Empty(t, recorder.Body.String())
}
func TestPluginTrailingSlashMissDoesNotRedirect(t *testing.T) {
for _, testCase := range []struct {
name string
routePath string
requestPath string
}{
{name: "declared without slash", routePath: "/vendor/job", requestPath: "/vendor/job/"},
{name: "declared with slash", routePath: "/vendor/job/", requestPath: "/vendor/job"},
} {
t.Run(testCase.name, func(t *testing.T) {
plugin := compileRouterPlugin(t, "slash-owner", "1.0.0", fmt.Sprintf(`[
{method: "GET", path: %q, type: "dynamic"}
]`, testCase.routePath))
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodGet, testCase.requestPath)
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "fallback", recorder.Body.String())
assert.Empty(t, recorder.Header().Get("Location"))
})
}
}
func TestPluginSSEFlushesWithoutFallbackBuffering(t *testing.T) {
plugin := compileRouterPlugin(t, "stream-owner", "1.0.0", `[
{method: "GET", path: "/vendor/stream", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.Header("Content-Type", "text/event-stream")
_, _ = c.Writer.WriteString("data: ready\n\n")
c.Writer.Flush()
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
middleware.RouteTag("web"),
gzip.Gzip(gzip.DefaultCompression),
middleware.Cache(),
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
request := httptest.NewRequest(http.MethodGet, "/vendor/stream", nil)
request.Header.Set("Accept-Encoding", "gzip")
recorder := httptest.NewRecorder()
outer.ServeHTTP(recorder, request)
assert.True(t, recorder.Flushed)
assert.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
assert.Equal(t, "data: ready\n\n", recorder.Body.String())
assert.Empty(t, recorder.Header().Get("Content-Encoding"))
assert.Empty(t, recorder.Header().Get("Cache-Control"))
assert.Empty(t, recorder.Header().Get("Cache-Version"))
}
func TestWebCacheHeadersDoNotLeakOntoPluginRoutes(t *testing.T) {
plugin := compileRouterPlugin(t, "cache-owner", "1.0.0", `[
{method: "GET", path: "/vendor/status/:task_id", type: "query", render: "native"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.JSON(http.StatusOK, gin.H{"status": "queued"})
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
middleware.Cache(),
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
pluginResponse := performPluginRequest(outer, http.MethodGet, "/vendor/status/task-1")
assert.Empty(t, pluginResponse.Header().Get("Cache-Control"))
assert.Empty(t, pluginResponse.Header().Get("Cache-Version"))
fallbackResponse := performPluginRequest(outer, http.MethodGet, "/unknown")
assert.Equal(t, "max-age=604800", fallbackResponse.Header().Get("Cache-Control"))
assert.NotEmpty(t, fallbackResponse.Header().Get("Cache-Version"))
}
func TestPluginInnerContextImportsRequestMetadataAndTrustedProxyConfig(t *testing.T) {
plugin := compileRouterPlugin(t, "context-owner", "1.0.0", `[
{method: "GET", path: "/vendor/context", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.JSON(http.StatusOK, gin.H{
"request_id": c.GetString(common.RequestIdKey),
"language": c.GetString(string(constant.ContextKeyLanguage)),
"client_ip": c.ClientIP(),
"route_tag": c.GetString(middleware.RouteTagKey),
})
})
outer, registry := newPluginRouterTestWithProxies(t, []*jsplugin.LoadedPlugin{plugin}, handlers, []string{"127.0.0.0/8"})
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
request := httptest.NewRequest(http.MethodGet, "/vendor/context", nil)
request.RemoteAddr = "127.0.0.1:1234"
request.Header.Set("X-Forwarded-For", "203.0.113.20")
recorder := httptest.NewRecorder()
outer.ServeHTTP(recorder, request)
assert.JSONEq(t, `{"request_id":"request-phase-two","language":"zh-CN","client_ip":"203.0.113.20","route_tag":"relay"}`, recorder.Body.String())
}
func TestPluginRequestPinsGenerationAcrossHotSwap(t *testing.T) {
v1 := compileRouterPlugin(t, "hot-swap", "1.0.0", `[
{method: "GET", path: "/vendor/version", type: "dynamic"}
]`)
v2 := compileRouterPlugin(t, "hot-swap", "2.0.0", `[
{method: "GET", path: "/vendor/version", type: "dynamic"}
]`)
started := make(chan struct{})
release := make(chan struct{})
var startOnce sync.Once
t.Cleanup(func() {
select {
case <-release:
default:
close(release)
}
})
var registry *jsplugin.Registry
handlers := testPluginRouteHandlers(func(c *gin.Context, generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
if binding.Plugin.Meta.Version == "1.0.0" {
startOnce.Do(func() { close(started) })
<-release
}
pinnedValue, exists := c.Get(jsplugin.ContextKeyPinnedRoute)
pinned, ok := pinnedValue.(jsplugin.PinnedRoute)
if !exists || !ok || pinned.Plugin == nil || pinned.Generation == nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{
"version": binding.Plugin.Meta.Version,
"generation": generation.Number,
"pinned_version": pinned.Plugin.Meta.Version,
"pinned_generation": pinned.Generation.Number,
"current_generation": registry.Generation().Number,
})
})
outer, activeRegistry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{v1}, handlers)
registry = activeRegistry
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
firstGeneration := registry.Generation().Number
firstDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
firstDone <- performPluginRequest(outer, http.MethodGet, "/vendor/version")
}()
select {
case <-started:
case <-time.After(2 * time.Second):
require.FailNow(t, "first generation request did not start")
}
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
secondGeneration := registry.Generation().Number
close(release)
var first *httptest.ResponseRecorder
select {
case first = <-firstDone:
case <-time.After(2 * time.Second):
require.FailNow(t, "pinned first generation request did not finish")
}
assert.JSONEq(t, fmt.Sprintf(`{
"version": "1.0.0",
"generation": %d,
"pinned_version": "1.0.0",
"pinned_generation": %d,
"current_generation": %d
}`, firstGeneration, firstGeneration, secondGeneration), first.Body.String())
second := performPluginRequest(outer, http.MethodGet, "/vendor/version")
assert.JSONEq(t, fmt.Sprintf(`{
"version": "2.0.0",
"generation": %d,
"pinned_version": "2.0.0",
"pinned_generation": %d,
"current_generation": %d
}`, secondGeneration, secondGeneration, secondGeneration), second.Body.String())
}
func TestPluginStaticRouteConflictsAreExcluded(t *testing.T) {
tests := []struct {
name string
staticPath string
pluginPath string
}{
{name: "parameter intersection", staticPath: "/core/:id", pluginPath: "/core/fixed"},
{name: "trailing slash redirect shadow", staticPath: "/fixed", pluginPath: "/fixed/"},
{name: "catchall redirect shadow", staticPath: "/files/*filepath", pluginPath: "/files"},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
outer := newOuterPluginTestEngine()
outer.GET(testCase.staticPath, func(c *gin.Context) { c.String(http.StatusOK, "static") })
registry := jsplugin.NewRegistry()
plugin := compileRouterPlugin(t, "static-conflict", "1.0.0", fmt.Sprintf(`[
{method: "POST", path: %q, type: "submit"}
]`, testCase.pluginPath))
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{plugin}))
builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
recorder := performPluginRequest(outer, http.MethodPost, testCase.pluginPath)
assert.Equal(t, "fallback", recorder.Body.String())
assert.Contains(t, registry.RoutingErrors()["static-conflict"], "intersects static route")
})
}
}
func TestStaticConflictingUpdateRetainsIncumbentAndPublishesHealthyPeer(t *testing.T) {
outer := newOuterPluginTestEngine()
outer.GET("/core/:id", func(c *gin.Context) { c.String(http.StatusOK, "static") })
registry := jsplugin.NewRegistry()
incumbent := compileRouterPlugin(t, "static-update", "1.0.0", `[
{method: "GET", path: "/safe/incumbent", type: "dynamic"}
]`)
conflictingUpdate := compileRouterPlugin(t, "static-update", "2.0.0", `[
{method: "POST", path: "/core/fixed", type: "submit"}
]`)
healthyV1 := compileRouterPlugin(t, "healthy-peer", "1.0.0", `[
{method: "GET", path: "/safe/healthy", type: "dynamic"}
]`)
healthyV2 := compileRouterPlugin(t, "healthy-peer", "2.0.0", `[
{method: "GET", path: "/safe/healthy", type: "dynamic"}
]`)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{incumbent, healthyV1}))
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{conflictingUpdate, healthyV2}))
activeIncumbent, ok := registry.Get("static-update")
require.True(t, ok)
assert.Same(t, incumbent, activeIncumbent)
activeHealthy, ok := registry.Get("healthy-peer")
require.True(t, ok)
assert.Same(t, healthyV2, activeHealthy)
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/safe/incumbent").Body.String())
assert.Equal(t, "2.0.0", performPluginRequest(outer, http.MethodGet, "/safe/healthy").Body.String())
assert.Contains(t, registry.RoutingErrors()["static-update"], "intersects static route")
}
func TestStaticConflictingNewOverrideRetainsFactoryRoute(t *testing.T) {
outer := newOuterPluginTestEngine()
outer.GET("/core/:id", func(c *gin.Context) { c.String(http.StatusOK, "static") })
registry := jsplugin.NewRegistry()
factory, err := registry.RegisterFactory(routerPluginSource("factory-route", "1.0.0", `[
{method: "GET", path: "/factory-route/safe", type: "dynamic"}
]`), jsplugin.Options{})
require.NoError(t, err)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
conflictingOverride := compileRouterPlugin(t, "factory-route", "2.0.0", `[
{method: "POST", path: "/core/fixed", type: "submit"}
]`)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{conflictingOverride}))
active, ok := registry.Get("factory-route")
require.True(t, ok)
assert.Same(t, factory, active)
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/factory-route/safe").Body.String())
assert.Contains(t, registry.RoutingErrors()["factory-route"], "intersects static route")
}
func TestPluginRouteOwnershipCanSwapWithinOneGeneration(t *testing.T) {
alphaV1 := compileRouterPlugin(t, "route-swap-alpha", "1.0.0", `[
{method: "GET", path: "/route-swap/alpha", type: "dynamic"}
]`)
betaV1 := compileRouterPlugin(t, "route-swap-beta", "1.0.0", `[
{method: "GET", path: "/route-swap/beta", type: "dynamic"}
]`)
alphaV2 := compileRouterPlugin(t, "route-swap-alpha", "2.0.0", `[
{method: "GET", path: "/route-swap/beta", type: "dynamic"}
]`)
betaV2 := compileRouterPlugin(t, "route-swap-beta", "2.0.0", `[
{method: "GET", path: "/route-swap/alpha", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key+"@"+binding.Plugin.Meta.Version)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{alphaV1, betaV1}, handlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alphaV2, betaV2}))
assert.Equal(t, "route-swap-beta@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-swap/alpha").Body.String())
assert.Equal(t, "route-swap-alpha@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-swap/beta").Body.String())
assert.Empty(t, registry.RoutingErrors())
}
func TestRejectedRouteUpdateDoesNotFreezeHealthyPeer(t *testing.T) {
alphaV1 := compileRouterPlugin(t, "route-fallback-alpha", "1.0.0", `[
{method: "GET", path: "/route-fallback/alpha", type: "dynamic"}
]`)
betaV1 := compileRouterPlugin(t, "route-fallback-beta", "1.0.0", `[
{method: "GET", path: "/route-fallback/beta", type: "dynamic"}
]`)
owner := compileRouterPlugin(t, "route-fallback-owner", "1.0.0", `[
{method: "GET", path: "/route-fallback/owner", type: "dynamic"}
]`)
alphaV2 := compileRouterPlugin(t, "route-fallback-alpha", "2.0.0", `[
{method: "POST", path: "/route-fallback/owner", type: "submit"}
]`)
betaV2 := compileRouterPlugin(t, "route-fallback-beta", "2.0.0", `[
{method: "GET", path: "/route-fallback/alpha", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key+"@"+binding.Plugin.Meta.Version)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{alphaV1, betaV1, owner}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alphaV2, betaV2, owner}))
_, alphaActive := registry.Get("route-fallback-alpha")
assert.False(t, alphaActive)
activeBeta, ok := registry.Get("route-fallback-beta")
require.True(t, ok)
assert.Same(t, betaV2, activeBeta)
assert.Equal(t, "route-fallback-beta@2.0.0", performPluginRequest(outer, http.MethodGet, "/route-fallback/alpha").Body.String())
assert.Contains(t, registry.RoutingErrors()["route-fallback-alpha"], "overlaps plugin route-fallback-owner")
assert.NotContains(t, registry.RoutingErrors(), "route-fallback-beta")
}
func TestPluginPathOwnershipIsExclusiveAcrossMethods(t *testing.T) {
alpha := compileRouterPlugin(t, "alpha-owner", "1.0.0", `[
{method: "GET", path: "/shared/jobs/:task_id", type: "query", render: "native"}
]`)
beta := compileRouterPlugin(t, "beta-owner", "1.0.0", `[
{method: "POST", path: "/shared/jobs/:task_id", type: "submit"}
]`)
// Display priority must not change exclusive route ownership.
alpha.Meta.SortPriority = -100
beta.Meta.SortPriority = 100
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{beta, alpha}, handlers)
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
getResponse := performPluginRequest(outer, http.MethodGet, "/shared/jobs/task-1")
assert.Equal(t, "alpha-owner", getResponse.Body.String())
postResponse := performPluginRequest(outer, http.MethodPost, "/shared/jobs/task-1")
assert.Equal(t, http.StatusMethodNotAllowed, postResponse.Code)
assert.Contains(t, registry.RoutingErrors()["beta-owner"], "overlaps plugin alpha-owner")
}
func TestOnePluginMayOwnMultipleMethodsForSamePath(t *testing.T) {
plugin := compileRouterPlugin(t, "multi-method", "1.0.0", `[
{method: "GET", path: "/vendor/multi/:task_id", type: "query", render: "native"},
{method: "POST", path: "/vendor/multi/:task_id", type: "submit"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, c.Request.Method)
})
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, handlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
assert.Equal(t, http.MethodGet, performPluginRequest(outer, http.MethodGet, "/vendor/multi/task-1").Body.String())
assert.Equal(t, http.MethodPost, performPluginRequest(outer, http.MethodPost, "/vendor/multi/task-1").Body.String())
assert.NotContains(t, registry.RoutingErrors(), "multi-method")
}
func TestGinWildcardNameConflictRejectsWholePlugin(t *testing.T) {
plugin := compileRouterPlugin(t, "wildcard-conflict", "1.0.0", `[
{method: "GET", path: "/vendor/:id/first", type: "query", render: "native", taskIdParam: "id"},
{method: "GET", path: "/vendor/:name/second", type: "query", render: "native", taskIdParam: "name"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
c.String(http.StatusOK, "plugin")
},
))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
assert.Equal(t, "fallback", performPluginRequest(outer, http.MethodGet, "/vendor/1/first").Body.String())
assert.Contains(t, registry.RoutingErrors()["wildcard-conflict"], "incompatible wildcard names")
}
func TestGinRegistrationPanicRebuildsWithoutOffender(t *testing.T) {
alpha := compileRouterPlugin(t, "panic-alpha", "1.0.0", `[
{method: "GET", path: "/panic/alpha", type: "dynamic"}
]`)
beta := compileRouterPlugin(t, "panic-beta", "1.0.0", `[
{method: "GET", path: "/panic/beta", type: "dynamic"}
]`)
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alpha, beta}))
outer := newOuterPluginTestEngine()
builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
},
))
normalRegister := builder.registerRoute
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, handlers []gin.HandlerFunc) {
if binding.Plugin.Meta.Key == "panic-beta" {
panic("registration failed")
}
normalRegister(engine, binding, handlers)
}
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute(
(&pluginRouteDispatcher{registry: registry}).dispatch,
func(c *gin.Context) { c.String(http.StatusOK, "fallback") },
)
assert.Equal(t, "panic-alpha", performPluginRequest(outer, http.MethodGet, "/panic/alpha").Body.String())
assert.Equal(t, "fallback", performPluginRequest(outer, http.MethodGet, "/panic/beta").Body.String())
assert.Contains(t, registry.RoutingErrors()["panic-beta"], "registration panic")
}
func TestGinRegistrationPanicReadmitsPluginBlockedByOffender(t *testing.T) {
alpha := compileRouterPlugin(t, "panic-owner-alpha", "1.0.0", `[
{method: "GET", path: "/panic/reconsider", type: "dynamic"}
]`)
beta := compileRouterPlugin(t, "panic-owner-beta", "1.0.0", `[
{method: "POST", path: "/panic/reconsider", type: "submit"}
]`)
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{alpha, beta}))
outer := newOuterPluginTestEngine()
builder := newPluginGenerationBuilder(outer.Routes(), nil, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
},
))
normalRegister := builder.registerRoute
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, handlers []gin.HandlerFunc) {
if binding.Plugin.Meta.Key == "panic-owner-alpha" {
panic("registration failed")
}
normalRegister(engine, binding, handlers)
}
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
_, alphaActive := registry.Get("panic-owner-alpha")
assert.False(t, alphaActive)
activeBeta, ok := registry.Get("panic-owner-beta")
require.True(t, ok)
assert.Same(t, beta, activeBeta)
assert.Equal(t, "panic-owner-beta", performPluginRequest(outer, http.MethodPost, "/panic/reconsider").Body.String())
assert.Contains(t, registry.RoutingErrors()["panic-owner-alpha"], "registration panic")
assert.NotContains(t, registry.RoutingErrors(), "panic-owner-beta")
}
func TestUpdatedRegistrationPanicRestoresIncumbent(t *testing.T) {
v1 := compileRouterPlugin(t, "panic-update", "1.0.0", `[
{method: "GET", path: "/panic/stable", type: "dynamic"}
]`)
v2 := compileRouterPlugin(t, "panic-update", "2.0.0", `[
{method: "GET", path: "/panic/stable", type: "dynamic"}
]`)
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v1}))
outer := newOuterPluginTestEngine()
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
normalRegister := builder.registerRoute
builder.registerRoute = func(engine *gin.Engine, binding jsplugin.RouteBinding, routeHandlers []gin.HandlerFunc) {
if binding.Plugin.Meta.Version == "2.0.0" {
panic("new version registration failed")
}
normalRegister(engine, binding, routeHandlers)
}
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
active, ok := registry.Get("panic-update")
require.True(t, ok)
assert.Same(t, v1, active)
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/panic/stable").Body.String())
assert.Contains(t, registry.RoutingErrors()["panic-update"], "registration panic")
}
func TestUnattributableRebuildFailureRetainsOldGeneration(t *testing.T) {
v1 := compileRouterPlugin(t, "rebuild-stable", "1.0.0", `[
{method: "GET", path: "/rebuild/version", type: "dynamic"}
]`)
v2 := compileRouterPlugin(t, "rebuild-stable", "2.0.0", `[
{method: "GET", path: "/rebuild/version", type: "dynamic"}
]`)
handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Version)
})
outer := newOuterPluginTestEngine()
registry := jsplugin.NewRegistry()
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v1}))
builder := newPluginGenerationBuilder(outer.Routes(), nil, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
before := registry.Generation()
normalConfigure := builder.configure
builder.configure = func(*gin.Engine) error { return errors.New("engine configuration failed") }
require.Error(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
assert.Same(t, before, registry.Generation())
assert.Equal(t, "1.0.0", performPluginRequest(outer, http.MethodGet, "/rebuild/version").Body.String())
assert.Contains(t, registry.LastRebuildError(), "engine configuration failed")
builder.configure = normalConfigure
require.NoError(t, registry.ReplaceOverrides([]*jsplugin.LoadedPlugin{v2}))
assert.Equal(t, "2.0.0", performPluginRequest(outer, http.MethodGet, "/rebuild/version").Body.String())
}
func TestPluginRouteRecoverySanitizesPanicResponse(t *testing.T) {
plugin := compileRouterPlugin(t, "panic-route", "1.0.0", `[
{method: "GET", path: "/vendor/panic", type: "dynamic"}
]`)
var calls atomic.Int32
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, testPluginRouteHandlers(
func(c *gin.Context, _ *jsplugin.RoutingGeneration, _ jsplugin.RouteBinding) {
if calls.Add(1) == 1 {
panic("https://secret.example/internal?token=credential")
}
c.String(http.StatusOK, "recovered")
},
))
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
recorder := performPluginRequest(outer, http.MethodGet, "/vendor/panic")
assert.Equal(t, http.StatusInternalServerError, recorder.Code)
assert.Contains(t, recorder.Body.String(), "internal plugin route error")
assert.NotContains(t, recorder.Body.String(), "secret.example")
assert.NotContains(t, recorder.Body.String(), "credential")
second := performPluginRequest(outer, http.MethodGet, "/vendor/panic")
assert.Equal(t, http.StatusOK, second.Code)
assert.Equal(t, "recovered", second.Body.String())
}
func TestProductionPluginRoutePipelineRequiresTokenAuth(t *testing.T) {
plugin := compileRouterPlugin(t, "forced-auth", "1.0.0", `[
{method: "GET", path: "/vendor/protected/:task_id", type: "query", render: "native"}
]`)
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{plugin}, productionPluginRouteHandlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
recorder := performPluginRequest(outer, http.MethodGet, "/vendor/protected/task-1")
assert.NotEqual(t, http.StatusNotImplemented, recorder.Code)
assert.Contains(t, recorder.Body.String(), "error")
}
func TestProductionPluginNativeQueryTraversesInnerRouter(t *testing.T) {
previousDB := model.DB
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.Task{}))
model.DB = database
t.Cleanup(func() { model.DB = previousDB })
require.NoError(t, database.Create(&model.Task{
TaskID: "task_native_router",
Platform: constant.TaskPlatform("kling"),
UserId: 91,
Status: model.TaskStatusSuccess,
Progress: "100%",
ChannelId: 17,
PrivateData: model.TaskPrivateData{
UpstreamTaskID: "private_upstream_id",
ResultURL: "https://secret.example/video.mp4",
},
}).Error)
kling, found := jsplugin.DefaultRegistry.Get("kling")
require.True(t, found)
authenticatedProductionHandlers := func(
generation *jsplugin.RoutingGeneration,
binding jsplugin.RouteBinding,
) []gin.HandlerFunc {
production := productionPluginRouteHandlers(generation, binding)
return []gin.HandlerFunc{
production[0],
func(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUserId, 91)
common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
common.SetContextKey(c, constant.ContextKeyTokenGroup, "default")
c.Next()
},
production[2],
production[3],
production[4],
production[5],
production[6],
}
}
outer, registry := newPluginRouterTest(t, []*jsplugin.LoadedPlugin{kling}, authenticatedProductionHandlers)
outer.NoRoute((&pluginRouteDispatcher{registry: registry}).dispatch)
request := httptest.NewRequest(http.MethodGet, "/kling/v1/videos/text2video/task_native_router", nil)
recorder := httptest.NewRecorder()
outer.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
assert.Contains(t, recorder.Body.String(), `"task_id":"task_native_router"`)
assert.Contains(t, recorder.Body.String(), `"task_status":"succeed"`)
assert.NotContains(t, recorder.Body.String(), "private_upstream_id")
assert.NotContains(t, recorder.Body.String(), "secret.example")
}
func newPluginRouterTest(
t *testing.T,
plugins []*jsplugin.LoadedPlugin,
handlers pluginRouteHandlers,
) (*gin.Engine, *jsplugin.Registry) {
t.Helper()
return newPluginRouterTestWithProxies(t, plugins, handlers, nil)
}
func newPluginRouterTestWithProxies(
t *testing.T,
plugins []*jsplugin.LoadedPlugin,
handlers pluginRouteHandlers,
trustedProxies []string,
) (*gin.Engine, *jsplugin.Registry) {
t.Helper()
gin.SetMode(gin.TestMode)
outer := newOuterPluginTestEngine()
registry := jsplugin.NewRegistry()
if plugins != nil {
require.NoError(t, registry.ReplaceOverrides(plugins))
}
if handlers == nil {
handlers = testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key)
})
}
builder := newPluginGenerationBuilder(outer.Routes(), trustedProxies, handlers)
require.NoError(t, registry.SetGenerationPreparer(builder.prepare))
return outer, registry
}
func newOuterPluginTestEngine() *gin.Engine {
outer := gin.New()
outer.Use(func(c *gin.Context) {
c.Set(common.RequestIdKey, "request-phase-two")
c.Set(string(constant.ContextKeyLanguage), "zh-CN")
c.Set(middleware.RouteTagKey, "before")
c.Next()
})
return outer
}
func testPluginRouteHandlers(
handler func(*gin.Context, *jsplugin.RoutingGeneration, jsplugin.RouteBinding),
) pluginRouteHandlers {
return func(generation *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) []gin.HandlerFunc {
return []gin.HandlerFunc{func(c *gin.Context) {
pinnedGeneration := generation
if state, _ := c.Request.Context().Value(pluginDispatchStateKey{}).(*pluginDispatchState); state != nil && state.generation != nil {
pinnedGeneration = state.generation
}
c.Set(jsplugin.ContextKeyPinnedRoute, jsplugin.PinnedRoute{
Generation: pinnedGeneration,
Plugin: binding.Plugin,
Route: binding.Route,
})
handler(c, pinnedGeneration, binding)
}}
}
}
func compileRouterPlugin(t *testing.T, key, version, routes string) *jsplugin.LoadedPlugin {
t.Helper()
plugin, err := jsplugin.CompilePlugin(routerPluginSource(key, version, routes), jsplugin.Options{Key: key, Version: version})
require.NoError(t, err)
return plugin
}
func routerPluginSource(key, version, routes string) string {
return fmt.Sprintf(`
export const meta = {
apiVersion: 1,
key: %q,
name: %q,
version: %q,
author: {name: "Test"},
models: ["model"],
fetchMode: "per_task",
routes: (%s).map(function(route) {
const migrated = Object.assign({}, route);
delete migrated.renderer;
migrated.render = route.render || route.renderer || "render";
if (route.type !== "query") migrated.decode = route.decode || "decode";
return migrated;
}),
};
export const native = {
decode: function(ctx) { return {kind: "submit", model: "model", requestBody: ctx.body.value}; },
render: function(ctx, task) { return task; },
native: function(ctx, task) { return task; },
};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, key, version, routes)
}
func performPluginRequest(handler http.Handler, method, path string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, strings.NewReader(""))
handler.ServeHTTP(recorder, request)
return recorder
}
func TestWebFallbackDoesNotCacheMissingAPIOrAssets(t *testing.T) {
outer := gin.New()
SetWebRouter(outer, WebAssets{IndexPage: []byte("dashboard")}, func(c *gin.Context) { c.Next() })
for _, path := range []string{"/api/user/token/status", "/api/audit/self?p=1", "/v1/missing", "/assets/missing.js"} {
t.Run(path, func(t *testing.T) {
response := performPluginRequest(outer, http.MethodGet, path)
assert.Equal(t, http.StatusNotFound, response.Code)
assert.Contains(t, response.Body.String(), "Invalid URL")
assert.Contains(t, response.Header().Get("Cache-Control"), "no-store")
assert.NotContains(t, response.Header().Get("Cache-Control"), "604800")
})
}
page := performPluginRequest(outer, http.MethodGet, "/security")
assert.Equal(t, http.StatusOK, page.Code)
assert.Equal(t, "dashboard", page.Body.String())
assert.Equal(t, "no-cache", page.Header().Get("Cache-Control"))
}
func TestSecurityRoutesDisableCachingBeforeAuthentication(t *testing.T) {
outer := gin.New()
SetApiRouter(outer)
for _, path := range []string{"/api/user/token/status", "/api/user/token", "/api/audit/self", "/api/audit"} {
t.Run(path, func(t *testing.T) {
response := performPluginRequest(outer, http.MethodGet, path)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Header().Get("Cache-Control"), "no-store")
})
}
}
+218
View File
@@ -0,0 +1,218 @@
package router
import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func SetRelayRouter(router *gin.Engine) {
router.Use(middleware.CORS())
router.Use(middleware.DecompressRequestMiddleware())
router.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
router.Use(middleware.StatsMiddleware())
// https://platform.openai.com/docs/api-reference/introduction
modelsRouter := router.Group("/v1/models")
modelsRouter.Use(middleware.RouteTag("relay"))
modelsRouter.Use(middleware.TokenAuth())
{
modelsRouter.GET("", func(c *gin.Context) {
switch {
case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "":
controller.ListModels(c, constant.ChannelTypeAnthropic)
case c.GetHeader("x-goog-api-key") != "" || c.Query("key") != "": // 单独的适配
controller.ListModels(c, constant.ChannelTypeGemini)
default:
controller.ListModels(c, constant.ChannelTypeOpenAI)
}
})
modelsRouter.GET("/:model", func(c *gin.Context) {
switch {
case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "":
controller.RetrieveModel(c, constant.ChannelTypeAnthropic)
default:
controller.RetrieveModel(c, constant.ChannelTypeOpenAI)
}
})
}
geminiRouter := router.Group("/v1beta/models")
geminiRouter.Use(middleware.RouteTag("relay"))
geminiRouter.Use(middleware.TokenAuth())
{
geminiRouter.GET("", func(c *gin.Context) {
controller.ListModels(c, constant.ChannelTypeGemini)
})
}
geminiCompatibleRouter := router.Group("/v1beta/openai/models")
geminiCompatibleRouter.Use(middleware.RouteTag("relay"))
geminiCompatibleRouter.Use(middleware.TokenAuth())
{
geminiCompatibleRouter.GET("", func(c *gin.Context) {
controller.ListModels(c, constant.ChannelTypeOpenAI)
})
}
playgroundRouter := router.Group("/pg")
playgroundRouter.Use(middleware.RouteTag("relay"))
playgroundRouter.Use(middleware.SystemPerformanceCheck())
playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute())
{
playgroundRouter.POST("/chat/completions", controller.Playground)
}
relayV1Router := router.Group("/v1")
relayV1Router.Use(middleware.RouteTag("relay"))
relayV1Router.Use(middleware.SystemPerformanceCheck())
relayV1Router.Use(middleware.TokenAuth())
relayV1Router.Use(middleware.ModelRequestRateLimit())
{
// WebSocket 路由(统一到 Relay)
wsRouter := relayV1Router.Group("")
wsRouter.Use(middleware.Distribute())
wsRouter.GET("/realtime", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIRealtime)
})
}
{
//http router
httpRouter := relayV1Router.Group("")
httpRouter.Use(middleware.Distribute())
// claude related routes
// TODO: /messages/count_tokens is disabled. The current controller.CountClaudeTokens
// httpRouter.POST("/messages/count_tokens", controller.CountClaudeTokens)
httpRouter.POST("/messages", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatClaude)
})
// chat related routes
httpRouter.POST("/completions", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAI)
})
httpRouter.POST("/chat/completions", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAI)
})
// response related routes
httpRouter.POST("/responses/compact", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIResponsesCompaction)
})
// alpha search related routes (Codex standalone web search)
httpRouter.POST("/alpha/search", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIAlphaSearch)
})
// image related routes
httpRouter.POST("/edits", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIImage)
})
httpRouter.POST("/images/generations", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIImage)
})
httpRouter.POST("/images/edits", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIImage)
})
// embedding related routes
httpRouter.POST("/embeddings", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatEmbedding)
})
// audio related routes
httpRouter.POST("/audio/transcriptions", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIAudio)
})
httpRouter.POST("/audio/translations", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIAudio)
})
httpRouter.POST("/audio/speech", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAIAudio)
})
// rerank related routes
httpRouter.POST("/rerank", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatRerank)
})
// gemini relay routes
httpRouter.POST("/engines/:model/embeddings", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatGemini)
})
httpRouter.POST("/models/*path", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatGemini)
})
// other relay routes
httpRouter.POST("/moderations", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatOpenAI)
})
// not implemented
httpRouter.POST("/images/variations", controller.RelayNotImplemented)
httpRouter.GET("/files", controller.RelayNotImplemented)
httpRouter.POST("/files", controller.RelayNotImplemented)
httpRouter.DELETE("/files/:id", controller.RelayNotImplemented)
httpRouter.GET("/files/:id", controller.RelayNotImplemented)
httpRouter.GET("/files/:id/content", controller.RelayNotImplemented)
httpRouter.POST("/fine-tunes", controller.RelayNotImplemented)
httpRouter.GET("/fine-tunes", controller.RelayNotImplemented)
httpRouter.GET("/fine-tunes/:id", controller.RelayNotImplemented)
httpRouter.POST("/fine-tunes/:id/cancel", controller.RelayNotImplemented)
httpRouter.GET("/fine-tunes/:id/events", controller.RelayNotImplemented)
httpRouter.DELETE("/models/:model", controller.RelayNotImplemented)
}
relayMjRouter := router.Group("/mj")
relayMjRouter.Use(middleware.RouteTag("relay"))
relayMjRouter.Use(middleware.SystemPerformanceCheck())
registerMjRouterGroup(relayMjRouter)
relayMjModeRouter := router.Group("/:mode/mj")
relayMjModeRouter.Use(middleware.RouteTag("relay"))
relayMjModeRouter.Use(middleware.SystemPerformanceCheck())
registerMjRouterGroup(relayMjModeRouter)
//relayMjRouter.Use()
relayGeminiRouter := router.Group("/v1beta")
relayGeminiRouter.Use(middleware.RouteTag("relay"))
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
relayGeminiRouter.Use(middleware.TokenAuth())
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
relayGeminiRouter.Use(middleware.Distribute())
{
// Gemini API 路径格式: /v1beta/models/{model_name}:{action}
relayGeminiRouter.POST("/models/*path", func(c *gin.Context) {
controller.Relay(c, types.RelayFormatGemini)
})
}
}
func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) {
relayMjRouter.GET("/image/:id", relay.RelayMidjourneyImage)
relayMjRouter.Use(middleware.TokenAuth(), middleware.Distribute())
{
relayMjRouter.POST("/submit/action", controller.RelayMidjourney)
relayMjRouter.POST("/submit/shorten", controller.RelayMidjourney)
relayMjRouter.POST("/submit/modal", controller.RelayMidjourney)
relayMjRouter.POST("/submit/imagine", controller.RelayMidjourney)
relayMjRouter.POST("/submit/change", controller.RelayMidjourney)
relayMjRouter.POST("/submit/simple-change", controller.RelayMidjourney)
relayMjRouter.POST("/submit/describe", controller.RelayMidjourney)
relayMjRouter.POST("/submit/blend", controller.RelayMidjourney)
relayMjRouter.POST("/submit/edits", controller.RelayMidjourney)
relayMjRouter.POST("/submit/video", controller.RelayMidjourney)
//relayMjRouter.POST("/notify", controller.RelayMidjourney)
relayMjRouter.GET("/task/:id/fetch", controller.RelayMidjourney)
relayMjRouter.GET("/task/:id/image-seed", controller.RelayMidjourney)
relayMjRouter.POST("/task/list-by-condition", controller.RelayMidjourney)
relayMjRouter.POST("/insight-face/swap", controller.RelayMidjourney)
relayMjRouter.POST("/submit/upload-discord-images", controller.RelayMidjourney)
}
}
+126
View File
@@ -0,0 +1,126 @@
package router
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestListModelsSupportsOpenAIAndGeminiAuthentication(t *testing.T) {
setupRelayRouterTestDB(t)
user := model.User{
Username: "models-user",
Status: common.UserStatusEnabled,
Group: "default",
Quota: 100,
}
require.NoError(t, model.DB.Create(&user).Error)
require.NoError(t, model.DB.Create(&model.Token{
UserId: user.Id,
Key: "modelstestkey",
Status: common.TokenStatusEnabled,
ExpiredTime: -1,
UnlimitedQuota: true,
}).Error)
engine := gin.New()
SetRelayRouter(engine)
tests := []struct {
name string
path string
headerName string
expectedObject string
expectedField string
}{
{
name: "OpenAI bearer token",
path: "/v1/models",
headerName: "Authorization",
expectedObject: "list",
expectedField: "data",
},
{
name: "Gemini API key header",
path: "/v1/models",
headerName: "x-goog-api-key",
expectedField: "models",
},
{
name: "Gemini API key query",
path: "/v1/models?key=modelstestkey",
expectedField: "models",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, test.path, nil)
if test.headerName != "" {
value := "modelstestkey"
if test.headerName == "Authorization" {
value = "Bearer " + value
}
request.Header.Set(test.headerName, value)
}
engine.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code)
var payload map[string]any
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
assert.Contains(t, payload, test.expectedField)
assert.NotContains(t, payload, "error")
if test.expectedObject != "" {
assert.Equal(t, test.expectedObject, payload["object"])
}
})
}
}
func setupRelayRouterTestDB(t *testing.T) {
t.Helper()
gin.SetMode(gin.TestMode)
originalIsMasterNode := common.IsMasterNode
originalRedisEnabled := common.RedisEnabled
originalSQLitePath := common.SQLitePath
originalMainDatabaseType := common.MainDatabaseType()
originalLogDatabaseType := common.LogDatabaseType()
originalSQLDSN, hadSQLDSN := os.LookupEnv("SQL_DSN")
common.IsMasterNode = false
common.RedisEnabled = false
common.SQLitePath = fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
require.NoError(t, os.Setenv("SQL_DSN", "local"))
require.NoError(t, model.InitDB())
model.LOG_DB = model.DB
require.NoError(t, model.DB.AutoMigrate(&model.User{}, &model.Token{}, &model.Ability{}))
t.Cleanup(func() {
if sqlDB, err := model.DB.DB(); err == nil {
_ = sqlDB.Close()
}
common.IsMasterNode = originalIsMasterNode
common.RedisEnabled = originalRedisEnabled
common.SQLitePath = originalSQLitePath
common.SetDatabaseTypes(originalMainDatabaseType, originalLogDatabaseType)
if hadSQLDSN {
require.NoError(t, os.Setenv("SQL_DSN", originalSQLDSN))
} else {
require.NoError(t, os.Unsetenv("SQL_DSN"))
}
})
}
+52
View File
@@ -0,0 +1,52 @@
package router
import (
"fmt"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func SetTaskPluginProtocolRouter(router *gin.Engine) {
for _, protocol := range pluginruntime.HostProtocols() {
for _, operation := range protocol.Operations {
for _, method := range operation.Methods {
handlers, err := taskPluginProtocolHandlers(protocol.Name, operation.Name)
if err != nil {
panic(err)
}
router.Handle(method, operation.Path, handlers...)
}
}
}
}
func taskPluginProtocolHandlers(protocol, operation string) ([]gin.HandlerFunc, error) {
switch protocol + "." + operation {
case "openai_responses.create":
return []gin.HandlerFunc{
middleware.RouteTag("relay"), middleware.SystemPerformanceCheck(), middleware.TokenAuth(),
middleware.ModelRequestRateLimit(), middleware.PinTaskPluginEndpoint(), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
func(c *gin.Context) {
controller.RelayTaskPluginEndpoint(c, func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIResponses) })
},
}, nil
case "openai_video.create":
return []gin.HandlerFunc{
middleware.RouteTag("relay"), middleware.TokenAuth(), middleware.SystemPerformanceCheck(),
middleware.PinTaskPluginEndpoint(), middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()), middleware.PrepareTaskPluginEndpoint(), middleware.Distribute(),
func(c *gin.Context) { controller.RelayTaskPluginEndpoint(c, controller.RelayTask) },
}, nil
case "openai_responses.retrieve":
return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), controller.RetrieveTaskPluginResponse}, nil
case "openai_video.retrieve":
return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), middleware.Distribute(), controller.RelayTaskFetch}, nil
case "openai_video.content":
return []gin.HandlerFunc{middleware.RouteTag("relay"), middleware.TokenAuth(), controller.VideoProxy}, nil
default:
return nil, fmt.Errorf("host protocol registry operation %s.%s has no handler", protocol, operation)
}
}
+37
View File
@@ -0,0 +1,37 @@
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-gonic/gin"
)
// SetTaskRouter registers the generic task-plugin API surface.
//
// Gin requires every route sharing a path position to use the same wildcard
// name, so the first segment is uniformly ":key"; it carries the plugin key
// on submit routes and the task id on read routes.
func SetTaskRouter(router *gin.Engine) {
taskSubmitRouter := router.Group("/v1/tasks")
taskSubmitRouter.Use(middleware.RouteTag("relay"), middleware.TokenAuth())
{
taskSubmitRouter.POST("/:key", middleware.PrepareTaskPluginSubmit(), middleware.Distribute(), controller.RelayTask)
}
taskReadRouter := router.Group("/v1/tasks")
taskReadRouter.Use(middleware.RouteTag("relay"), middleware.TokenAuth())
{
taskReadRouter.GET("/:key", controller.GetTask)
taskReadRouter.GET("/:key/artifacts", controller.GetTaskArtifacts)
}
taskContentRouter := router.Group("/v1/tasks")
taskContentRouter.Use(
middleware.RouteTag("relay"),
middleware.TokenOrTaskArtifactAccessAuth("key", "artifact_key"),
)
{
taskContentRouter.GET("/:key/artifacts/:artifact_key/content", controller.TaskArtifactContent)
taskContentRouter.HEAD("/:key/artifacts/:artifact_key/content", controller.TaskArtifactContent)
}
}
+55
View File
@@ -0,0 +1,55 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestGetTaskPluginOptionsAdminForbiddenRootAllowed(t *testing.T) {
wasMaster := common.IsMasterNode
common.IsMasterNode = true
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, db.AutoMigrate(&model.CasbinRule{}, &model.AuthzRole{}))
require.NoError(t, authz.Init(db))
t.Cleanup(func() { common.IsMasterNode = wasMaster })
gin.SetMode(gin.TestMode)
for _, testCase := range []struct {
name string
id int
role int
wantStatus int
}{
{name: "admin", id: 2, role: common.RoleAdminUser, wantStatus: http.StatusForbidden},
{name: "root", id: 1, role: common.RoleRootUser, wantStatus: http.StatusOK},
} {
t.Run(testCase.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/task_plugin_options", nil)
context.Set("id", testCase.id)
context.Set("role", testCase.role)
middleware.RequirePermission(authz.TaskPluginBind)(context)
if !context.IsAborted() {
controller.GetTaskPluginOptions(context)
}
assert.Equal(t, testCase.wantStatus, recorder.Code)
})
}
}
@@ -0,0 +1,31 @@
package router
import (
"fmt"
"sort"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestHostProtocolRegistryDrivesProtocolRoutesOnce(t *testing.T) {
engine := gin.New()
SetTaskPluginProtocolRouter(engine)
expected := []string{
"POST /v1/responses",
"GET /v1/responses/:response_id",
"POST /v1/videos",
"GET /v1/videos/:task_id",
"GET /v1/videos/:task_id/content",
"HEAD /v1/videos/:task_id/content",
}
actual := make([]string, 0, len(engine.Routes()))
for _, route := range engine.Routes() {
actual = append(actual, fmt.Sprintf("%s %s", route.Method, route.Path))
}
sort.Strings(expected)
sort.Strings(actual)
assert.Equal(t, expected, actual)
}
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright (C) 2023-2026 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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
package router
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Gin panics at registration time when routes sharing a path position use
// different wildcard names, which unit tests that build their own routers
// never catch. Registering against a real engine is the only guard.
func TestSetTaskRouterRegistersWithoutConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
require.NotPanics(t, func() { SetTaskRouter(engine) })
routes := engine.Routes()
require.Len(t, routes, 5)
actual := make(map[string]struct{}, len(routes))
for _, route := range routes {
actual[route.Method+" "+route.Path] = struct{}{}
}
assert.Contains(t, actual, http.MethodPost+" /v1/tasks/:key")
assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key")
assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key/artifacts")
assert.Contains(t, actual, http.MethodGet+" /v1/tasks/:key/artifacts/:artifact_key/content")
assert.Contains(t, actual, http.MethodHead+" /v1/tasks/:key/artifacts/:artifact_key/content")
for route := range actual {
assert.NotContains(t, route, "/native/")
}
}
+33
View File
@@ -0,0 +1,33 @@
package router
import (
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-gonic/gin"
)
func SetVideoRouter(router *gin.Engine) {
videoSharedRouter := router.Group("/v1")
videoSharedRouter.Use(middleware.RouteTag("relay"))
videoSharedRouter.Use(middleware.TokenAuth())
videoSharedRouter.Use(middleware.SystemPerformanceCheck())
videoSharedRouter.POST(
"/video/generations",
middleware.PinTaskPluginEndpoint(),
middleware.TaskPluginEndpointOnly(middleware.ModelRequestRateLimit()),
middleware.PrepareTaskPluginEndpoint(),
middleware.Distribute(),
func(c *gin.Context) {
controller.RelayTaskPluginEndpoint(c, controller.RelayTask)
},
)
videoV1Router := router.Group("/v1")
videoV1Router.Use(middleware.RouteTag("relay"))
videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
{
videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch)
videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask)
}
}
+151
View File
@@ -0,0 +1,151 @@
package router
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetOpenAIVideoRouteRendersJimengTask(t *testing.T) {
gin.SetMode(gin.TestMode)
previousDB := model.DB
previousDatabaseType := common.MainDatabaseType()
previousLogDatabaseType := common.LogDatabaseType()
previousSQLitePath := common.SQLitePath
previousMasterNode := common.IsMasterNode
previousRedisEnabled := common.RedisEnabled
common.SQLitePath = t.TempDir() + "/router-video.db"
common.IsMasterNode = false
common.RedisEnabled = false
t.Setenv("SQL_DSN", "")
require.NoError(t, model.InitDB())
database := model.DB
require.NoError(t, database.AutoMigrate(&model.User{}, &model.Token{}, &model.Channel{}, &model.Task{}))
t.Cleanup(func() {
sqlDB, closeErr := database.DB()
require.NoError(t, closeErr)
require.NoError(t, sqlDB.Close())
model.DB = previousDB
common.SetDatabaseTypes(previousDatabaseType, previousLogDatabaseType)
common.SQLitePath = previousSQLitePath
common.IsMasterNode = previousMasterNode
common.RedisEnabled = previousRedisEnabled
})
require.NoError(t, database.Create(&model.User{
Id: 91,
Username: "jimeng-fetch-user",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Quota: 100,
Group: "default",
AuthVersion: 1,
}).Error)
require.NoError(t, database.Create(&model.Token{
Id: 1,
UserId: 91,
Key: "jimengfetch",
Status: common.TokenStatusEnabled,
Name: "jimeng fetch",
ExpiredTime: -1,
UnlimitedQuota: true,
}).Error)
require.NoError(t, database.Create(&model.Channel{
Id: 17,
Type: constant.ChannelTypeJimeng,
Key: "unused",
Status: common.ChannelStatusEnabled,
Name: "jimeng fetch",
Models: "jimeng_vgfm_t2v_l20",
Group: "default",
}).Error)
task := &model.Task{
CreatedAt: 1710000000,
UpdatedAt: 1710000060,
TaskID: "task_jimeng_public",
Platform: constant.TaskPlatform("jimeng"),
UserId: 91,
Group: "default",
ChannelId: 17,
Status: model.TaskStatusSuccess,
Progress: "100%",
PrivateData: model.TaskPrivateData{
ResultURL: "data:video/mp4;base64,ZGF0YQ==",
},
}
task.SetData(map[string]any{
"code": 10000,
"data": map[string]any{
"status": "done",
"task_id": "jimeng-private-1",
"video_url": "https://cdn.example/video.mp4",
},
"message": "success",
})
require.NoError(t, database.Create(task).Error)
engine := gin.New()
SetVideoRouter(engine)
SetTaskPluginProtocolRouter(engine)
request := httptest.NewRequest(http.MethodGet, "/v1/videos/task_jimeng_public", nil)
request.Header.Set("Authorization", "Bearer sk-jimengfetch")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
var response struct {
ID string `json:"id"`
Object string `json:"object"`
Status string `json:"status"`
Progress int `json:"progress"`
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "task_jimeng_public", response.ID)
assert.Equal(t, "video", response.Object)
assert.Equal(t, "completed", response.Status)
assert.Equal(t, 100, response.Progress)
assert.Equal(t, int64(1710000000), response.CreatedAt)
assert.Equal(t, int64(1710000060), response.CompletedAt)
assert.NotContains(t, recorder.Body.String(), "cdn.example")
assert.NotContains(t, recorder.Body.String(), "jimeng-private-1")
for _, testCase := range []struct {
name string
authorization string
query string
wantStatus int
}{
{name: "missing credential rejected", wantStatus: http.StatusUnauthorized},
{name: "access rejected", query: "?access=not-a-video-credential", wantStatus: http.StatusUnauthorized},
{name: "bearer accepted", authorization: "Bearer sk-jimengfetch", wantStatus: http.StatusOK},
} {
t.Run(testCase.name, func(t *testing.T) {
request := httptest.NewRequest(
http.MethodGet,
"/v1/videos/task_jimeng_public/content"+testCase.query,
nil,
)
if testCase.authorization != "" {
request.Header.Set("Authorization", testCase.authorization)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
assert.Equal(t, testCase.wantStatus, recorder.Code, recorder.Body.String())
if testCase.wantStatus == http.StatusOK {
assert.Equal(t, "data", recorder.Body.String())
}
})
}
}
+42
View File
@@ -0,0 +1,42 @@
package router
import (
"embed"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-contrib/gzip"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
)
// WebAssets holds the embedded dashboard frontend assets.
type WebAssets struct {
BuildFS embed.FS
IndexPage []byte
}
func SetWebRouter(router *gin.Engine, assets WebAssets, pluginDispatcher gin.HandlerFunc) {
frontendFS := common.EmbedFolder(assets.BuildFS, "web/dist")
router.NoRoute(
pluginDispatcher,
middleware.RouteTag("web"),
gzip.Gzip(gzip.DefaultCompression),
middleware.AccessTokenAudit(),
middleware.GlobalWebRateLimit(),
middleware.Cache(),
static.Serve("/", frontendFS),
func(c *gin.Context) {
if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
controller.RelayNotFound(c)
return
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/html; charset=utf-8", assets.IndexPage)
},
)
}