mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-10 22:20:25 +00:00
feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)
This commit is contained in:
+20
-1
@@ -3,6 +3,7 @@ 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"
|
||||
@@ -231,6 +232,23 @@ func SetApiRouter(router *gin.Engine) {
|
||||
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/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")
|
||||
@@ -327,7 +345,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
taskRoute := apiRouter.Group("/task")
|
||||
{
|
||||
taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask)
|
||||
taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask)
|
||||
taskRoute.GET("", middleware.AdminAuth(), controller.GetAllTask)
|
||||
taskRoute.GET("/:task_id/artifacts", middleware.UserAuth(), controller.GetDashboardTaskArtifacts)
|
||||
}
|
||||
|
||||
vendorRoute := apiRouter.Group("/vendors")
|
||||
|
||||
+11
-5
@@ -16,19 +16,25 @@ 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)
|
||||
SetWebRouter(router, assets, pluginDispatcher)
|
||||
} else {
|
||||
frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.Set(middleware.RouteTagKey, "web")
|
||||
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
|
||||
})
|
||||
router.NoRoute(
|
||||
pluginDispatcher,
|
||||
middleware.RouteTag("web"),
|
||||
func(c *gin.Context) {
|
||||
c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
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 := len(left)
|
||||
if len(right) < limit {
|
||||
limit = len(right)
|
||||
}
|
||||
for index := 0; index < limit; index++ {
|
||||
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,884 @@
|
||||
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"}
|
||||
]`)
|
||||
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
|
||||
}
|
||||
@@ -98,9 +98,6 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
})
|
||||
|
||||
// response related routes
|
||||
httpRouter.POST("/responses", func(c *gin.Context) {
|
||||
controller.Relay(c, types.RelayFormatOpenAIResponses)
|
||||
})
|
||||
httpRouter.POST("/responses/compact", func(c *gin.Context) {
|
||||
controller.Relay(c, types.RelayFormatOpenAIResponsesCompaction)
|
||||
})
|
||||
@@ -181,16 +178,6 @@ func SetRelayRouter(router *gin.Engine) {
|
||||
registerMjRouterGroup(relayMjModeRouter)
|
||||
//relayMjRouter.Use()
|
||||
|
||||
relaySunoRouter := router.Group("/suno")
|
||||
relaySunoRouter.Use(middleware.RouteTag("relay"))
|
||||
relaySunoRouter.Use(middleware.SystemPerformanceCheck())
|
||||
relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
relaySunoRouter.POST("/submit/:action", controller.RelayTask)
|
||||
relaySunoRouter.POST("/fetch", controller.RelayTaskFetch)
|
||||
relaySunoRouter.GET("/fetch/:id", controller.RelayTaskFetch)
|
||||
}
|
||||
|
||||
relayGeminiRouter := router.Group("/v1beta")
|
||||
relayGeminiRouter.Use(middleware.RouteTag("relay"))
|
||||
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRetiredFrontendAPIRoutes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
SetApiRouter(engine)
|
||||
|
||||
routes := make(map[string]struct{}, len(engine.Routes()))
|
||||
for _, route := range engine.Routes() {
|
||||
routes[route.Method+" "+route.Path] = struct{}{}
|
||||
}
|
||||
_, hasAsyncCleanup := routes[http.MethodPost+" /api/system-task/log-cleanup"]
|
||||
_, hasDirectDelete := routes[http.MethodDelete+" /api/log/"]
|
||||
_, hasConsoleMigration := routes[http.MethodPost+" /api/option/migrate_console_setting"]
|
||||
assert.True(t, hasAsyncCleanup)
|
||||
assert.False(t, hasDirectDelete)
|
||||
assert.False(t, hasConsoleMigration)
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
}
|
||||
+14
-33
@@ -8,45 +8,26 @@ import (
|
||||
)
|
||||
|
||||
func SetVideoRouter(router *gin.Engine) {
|
||||
// Video proxy: accepts either session auth (dashboard) or token auth (API clients)
|
||||
videoProxyRouter := router.Group("/v1")
|
||||
videoProxyRouter.Use(middleware.RouteTag("relay"))
|
||||
videoProxyRouter.Use(middleware.TokenOrUserAuth())
|
||||
{
|
||||
videoProxyRouter.GET("/videos/:task_id/content", controller.VideoProxy)
|
||||
}
|
||||
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.POST("/video/generations", controller.RelayTask)
|
||||
videoV1Router.GET("/video/generations/:task_id", controller.RelayTaskFetch)
|
||||
videoV1Router.POST("/videos/:video_id/remix", controller.RelayTask)
|
||||
}
|
||||
// openai compatible API video routes
|
||||
// docs: https://platform.openai.com/docs/api-reference/videos/create
|
||||
{
|
||||
videoV1Router.POST("/videos", controller.RelayTask)
|
||||
videoV1Router.GET("/videos/:task_id", controller.RelayTaskFetch)
|
||||
}
|
||||
|
||||
klingV1Router := router.Group("/kling/v1")
|
||||
klingV1Router.Use(middleware.RouteTag("relay"))
|
||||
klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
klingV1Router.POST("/videos/text2video", controller.RelayTask)
|
||||
klingV1Router.POST("/videos/image2video", controller.RelayTask)
|
||||
klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTaskFetch)
|
||||
klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTaskFetch)
|
||||
}
|
||||
|
||||
// Jimeng official API routes - direct mapping to official API format
|
||||
jimengOfficialGroup := router.Group("jimeng")
|
||||
jimengOfficialGroup.Use(middleware.RouteTag("relay"))
|
||||
jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
// Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
|
||||
jimengOfficialGroup.POST("/", 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -19,20 +19,23 @@ type WebAssets struct {
|
||||
IndexPage []byte
|
||||
}
|
||||
|
||||
func SetWebRouter(router *gin.Engine, assets WebAssets) {
|
||||
func SetWebRouter(router *gin.Engine, assets WebAssets, pluginDispatcher gin.HandlerFunc) {
|
||||
frontendFS := common.EmbedFolder(assets.BuildFS, "web/dist")
|
||||
|
||||
router.Use(gzip.Gzip(gzip.DefaultCompression))
|
||||
router.Use(middleware.GlobalWebRateLimit())
|
||||
router.Use(middleware.Cache())
|
||||
router.Use(static.Serve("/", frontendFS))
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.Set(middleware.RouteTagKey, "web")
|
||||
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)
|
||||
})
|
||||
router.NoRoute(
|
||||
pluginDispatcher,
|
||||
middleware.RouteTag("web"),
|
||||
gzip.Gzip(gzip.DefaultCompression),
|
||||
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