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
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:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user