Commit 99974a81 by CaIon

feat(plugins): extend plugin metadata and icon support

parent 75e53320
...@@ -472,12 +472,20 @@ func validateChannel(channel *model.Channel, isAdd bool) error { ...@@ -472,12 +472,20 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
if len(pluginKey) > 30 { if len(pluginKey) > 30 {
return fmt.Errorf("task plugin key must not exceed 30 characters") return fmt.Errorf("task plugin key must not exceed 30 characters")
} }
if _, ok := jsplugin.DefaultRegistry.Get(pluginKey); !ok { plugin, ok := jsplugin.DefaultRegistry.Get(pluginKey)
if !ok {
return fmt.Errorf("task plugin %q is not registered", pluginKey) return fmt.Errorf("task plugin %q is not registered", pluginKey)
} }
if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" { if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" {
// The plugin default is persisted onto the channel instead of being
// resolved per request, so the destination host stays an auditable
// channel property that only an administrator edit can change.
if plugin.Meta.BaseURL == "" {
return fmt.Errorf("base URL is required for task plugin channels") return fmt.Errorf("base URL is required for task plugin channels")
} }
defaultBaseURL := plugin.Meta.BaseURL
channel.BaseURL = &defaultBaseURL
}
} }
if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" { if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" {
...@@ -625,6 +633,9 @@ func AddChannel(c *gin.Context) { ...@@ -625,6 +633,9 @@ func AddChannel(c *gin.Context) {
return return
} }
baseURLFromPluginDefault := addChannelRequest.Channel != nil &&
addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin &&
(addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "")
// 使用统一的校验函数 // 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil { if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -709,11 +720,15 @@ func AddChannel(c *gin.Context) { ...@@ -709,11 +720,15 @@ func AddChannel(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
recordManageAudit(c, "channel.create", map[string]interface{}{ createAudit := map[string]interface{}{
"name": addChannelRequest.Channel.Name, "name": addChannelRequest.Channel.Name,
"type": addChannelRequest.Channel.Type, "type": addChannelRequest.Channel.Type,
"count": len(channels), "count": len(channels),
}) }
if baseURLFromPluginDefault {
createAudit["base_url_source"] = "plugin_default"
}
recordManageAudit(c, "channel.create", createAudit)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "", "message": "",
...@@ -981,6 +996,8 @@ func UpdateChannel(c *gin.Context) { ...@@ -981,6 +996,8 @@ func UpdateChannel(c *gin.Context) {
return return
} }
baseURLFromPluginDefault := channel.Type == constant.ChannelTypeTaskPlugin &&
(channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "")
// 使用统一的校验函数 // 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil { if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -1126,11 +1143,15 @@ func UpdateChannel(c *gin.Context) { ...@@ -1126,11 +1143,15 @@ func UpdateChannel(c *gin.Context) {
if channel.Key != "" && channel.Key != originChannel.Key { if channel.Key != "" && channel.Key != originChannel.Key {
changedFields = append(changedFields, "key") changedFields = append(changedFields, "key")
} }
recordManageAudit(c, "channel.update", map[string]interface{}{ updateAudit := map[string]interface{}{
"id": channel.Id, "id": channel.Id,
"name": channel.Name, "name": channel.Name,
"changed_fields": changedFields, "changed_fields": changedFields,
}) }
if baseURLFromPluginDefault {
updateAudit["base_url_source"] = "plugin_default"
}
recordManageAudit(c, "channel.update", updateAudit)
channel.Key = "" channel.Key = ""
clearChannelInfo(&channel.Channel) clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
......
...@@ -129,3 +129,38 @@ export function parseTaskResult() { return {}; } ...@@ -129,3 +129,38 @@ export function parseTaskResult() { return {}; }
assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission") assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, recorder.Body.String(), `"success":false`) assert.Contains(t, recorder.Body.String(), `"success":false`)
} }
func TestAddChannelTaskPluginPersistsPluginDefaultBaseURLAndAuditsSource(t *testing.T) {
setupTaskPluginBindChannelTest(t)
for key, baseURLField := range map[string]string{"bind-default-url": `baseUrl: "http://10.0.0.5:8000/",`, "bind-no-default": ""} {
source := fmt.Sprintf(`
export const meta = {apiVersion: 1, key: %q, name: "Bind", version: "1.0.0", author: {name: "Test"}, %s models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`, key, baseURLField)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
}
body := func(pluginKey string) string {
return fmt.Sprintf(`{"mode":"single","channel":{"type":61,"name":"%s","key":"sk","models":"doc","group":"default","setting":"{\"task_plugin_key\":\"%s\"}"}}`, pluginKey, pluginKey)
}
noDefault := postAddChannel(t, 1, common.RoleRootUser, body("bind-no-default"))
assert.Contains(t, noDefault.Body.String(), "base URL is required for task plugin channels")
filled := postAddChannel(t, 1, common.RoleRootUser, body("bind-default-url"))
require.Contains(t, filled.Body.String(), `"success":true`)
var created model.Channel
require.NoError(t, model.DB.Where("name = ?", "bind-default-url").First(&created).Error)
require.NotNil(t, created.BaseURL)
assert.Equal(t, "http://10.0.0.5:8000", *created.BaseURL, "the normalized plugin default is stored on the channel row")
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "channel.create").Find(&audits).Error)
encoded, err := common.Marshal(audits)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`)
}
...@@ -7,6 +7,7 @@ import ( ...@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -39,3 +40,30 @@ export function parseTaskResult() { return {}; } ...@@ -39,3 +40,30 @@ export function parseTaskResult() { return {}; }
channel.BaseURL = nil channel.BaseURL = nil
require.ErrorContains(t, validateChannel(channel, false), "base URL is required") require.ErrorContains(t, validateChannel(channel, false), "base URL is required")
} }
func TestValidateTaskPluginChannelFillsPluginDefaultBaseURL(t *testing.T) {
source := `
export const meta = {apiVersion: 1, key: "channel-default-url", name: "Default URL", version: "1.0.0", author: {name: "Test"}, baseUrl: "http://127.0.0.1:8000/", models: ["doc"], fetchMode: "per_task"};
export function buildSubmitRequest() { return {}; }
export function parseSubmitResponse() { return {}; }
export function buildQueryRequest() { return {}; }
export function parseTaskResult() { return {}; }
`
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-default-url") })
bound := `{"task_plugin_key":"channel-default-url"}`
empty := " "
for _, baseURL := range []*string{nil, &empty} {
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Key: "sk", Setting: &bound, BaseURL: baseURL}
require.NoError(t, validateChannel(channel, true))
require.NotNil(t, channel.BaseURL)
assert.Equal(t, "http://127.0.0.1:8000", *channel.BaseURL, "normalized plugin default is persisted onto the channel")
}
explicit := "https://override.example.com"
channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Setting: &bound, BaseURL: &explicit}
require.NoError(t, validateChannel(channel, false))
assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default")
}
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"sort" "sort"
"strings" "strings"
...@@ -30,6 +31,9 @@ type taskPluginUploadRequest struct { ...@@ -30,6 +31,9 @@ type taskPluginUploadRequest struct {
Remark string `json:"remark"` Remark string `json:"remark"`
Force bool `json:"force"` Force bool `json:"force"`
SourceSha256 string `json:"sourceSha256"` SourceSha256 string `json:"sourceSha256"`
// Icon carries the sidecar icon.svg / icon.png as a data URI. It is optional
// and stored separately from the source so the JavaScript stays readable.
Icon string `json:"icon"`
} }
func UploadTaskPlugin(c *gin.Context) { func UploadTaskPlugin(c *gin.Context) {
...@@ -59,6 +63,13 @@ func UploadTaskPlugin(c *gin.Context) { ...@@ -59,6 +63,13 @@ func UploadTaskPlugin(c *gin.Context) {
common.ApiErrorMsg(c, err.Error()) common.ApiErrorMsg(c, err.Error())
return return
} }
icon := strings.TrimSpace(request.Icon)
if icon != "" {
if _, _, err = jsplugin.DecodeIconDataURI(icon); err != nil {
common.ApiErrorMsg(c, err.Error())
return
}
}
enabled := true enabled := true
if request.Enabled != nil { if request.Enabled != nil {
enabled = *request.Enabled enabled = *request.Enabled
...@@ -72,7 +83,7 @@ func UploadTaskPlugin(c *gin.Context) { ...@@ -72,7 +83,7 @@ func UploadTaskPlugin(c *gin.Context) {
plugin := model.TaskPlugin{ plugin := model.TaskPlugin{
Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version, Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))), Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
Enabled: enabled, Remark: request.Remark, Icon: icon, Enabled: enabled, Remark: request.Remark,
} }
if err = model.SaveTaskPlugin(&plugin); err != nil { if err = model.SaveTaskPlugin(&plugin); err != nil {
common.ApiError(c, err) common.ApiError(c, err)
...@@ -82,7 +93,7 @@ func UploadTaskPlugin(c *gin.Context) { ...@@ -82,7 +93,7 @@ func UploadTaskPlugin(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"}) common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
} }
func GetTaskPluginVersions(c *gin.Context) { func GetTaskPluginVersions(c *gin.Context) {
...@@ -100,6 +111,7 @@ type taskPluginListItem struct { ...@@ -100,6 +111,7 @@ type taskPluginListItem struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Active bool `json:"active"` Active bool `json:"active"`
SourceHash string `json:"source_hash"` SourceHash string `json:"source_hash"`
HasIcon bool `json:"has_icon"`
Remark string `json:"remark"` Remark string `json:"remark"`
RuntimeStatus string `json:"runtime_status"` RuntimeStatus string `json:"runtime_status"`
RuntimeError string `json:"runtime_error,omitempty"` RuntimeError string `json:"runtime_error,omitempty"`
...@@ -179,6 +191,7 @@ func ListTaskPlugins(c *gin.Context) { ...@@ -179,6 +191,7 @@ func ListTaskPlugins(c *gin.Context) {
item.Enabled = row.Enabled item.Enabled = row.Enabled
item.Active = row.Active item.Active = row.Active
item.SourceHash = row.SourceHash item.SourceHash = row.SourceHash
item.HasIcon = row.HasIcon()
item.Remark = row.Remark item.Remark = row.Remark
if message := runtimeErrors[key]; message != "" { if message := runtimeErrors[key]; message != "" {
item.RuntimeStatus = "compile_failed" item.RuntimeStatus = "compile_failed"
...@@ -199,6 +212,7 @@ func ListTaskPlugins(c *gin.Context) { ...@@ -199,6 +212,7 @@ func ListTaskPlugins(c *gin.Context) {
item.Source = "factory" item.Source = "factory"
item.Meta = factoryMeta item.Meta = factoryMeta
item.Enabled = !setting.IsTaskPluginFactoryDisabled(key) item.Enabled = !setting.IsTaskPluginFactoryDisabled(key)
_, _, item.HasIcon = plugins.Icon(key)
source, sourceErr := plugins.Source(key) source, sourceErr := plugins.Source(key)
if sourceErr == nil { if sourceErr == nil {
item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source))) item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source)))
...@@ -221,7 +235,12 @@ func ListTaskPlugins(c *gin.Context) { ...@@ -221,7 +235,12 @@ func ListTaskPlugins(c *gin.Context) {
} }
items = append(items, item) items = append(items, item)
} }
sort.Slice(items, func(i, j int) bool { return items[i].Meta.Key < items[j].Meta.Key }) sort.Slice(items, func(i, j int) bool {
if items[i].Meta.SortPriority != items[j].Meta.SortPriority {
return items[i].Meta.SortPriority > items[j].Meta.SortPriority
}
return items[i].Meta.Key < items[j].Meta.Key
})
common.ApiSuccess(c, items) common.ApiSuccess(c, items)
} }
...@@ -277,6 +296,39 @@ type taskPluginDetail struct { ...@@ -277,6 +296,39 @@ type taskPluginDetail struct {
Meta jsplugin.Meta `json:"meta"` Meta jsplugin.Meta `json:"meta"`
Source string `json:"source"` Source string `json:"source"`
Layer string `json:"layer"` Layer string `json:"layer"`
HasIcon bool `json:"has_icon"`
}
// GetTaskPluginIcon serves a plugin logo as an image. The active override wins
// (or the requested ?version=), then the factory sidecar. Data icons are only
// ever drawn through <img>, and the nosniff header keeps a browser from
// treating an SVG response as a document.
func GetTaskPluginIcon(c *gin.Context) {
key := c.Param("key")
icon := ""
plugin, err := model.GetTaskPluginVersion(key, c.Query("version"))
if err == nil {
icon = plugin.Icon
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
common.ApiError(c, err)
return
}
if icon == "" && c.Query("version") == "" {
icon = plugins.IconDataURI(key)
}
if icon == "" {
c.AbortWithStatus(http.StatusNotFound)
return
}
mediaType, data, err := jsplugin.DecodeIconDataURI(icon)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.Header("Cache-Control", "private, max-age=3600")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
c.Data(http.StatusOK, mediaType, data)
} }
func GetTaskPlugin(c *gin.Context) { func GetTaskPlugin(c *gin.Context) {
...@@ -289,7 +341,7 @@ func GetTaskPlugin(c *gin.Context) { ...@@ -289,7 +341,7 @@ func GetTaskPlugin(c *gin.Context) {
common.ApiErrorMsg(c, compileErr.Error()) common.ApiErrorMsg(c, compileErr.Error())
return return
} }
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"}) common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
return return
} }
if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" { if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
...@@ -306,7 +358,8 @@ func GetTaskPlugin(c *gin.Context) { ...@@ -306,7 +358,8 @@ func GetTaskPlugin(c *gin.Context) {
common.ApiError(c, err) common.ApiError(c, err)
return return
} }
common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory"}) _, _, hasIcon := plugins.Icon(key)
common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory", HasIcon: hasIcon})
} }
type taskPluginDryRunRequest struct { type taskPluginDryRunRequest struct {
...@@ -590,15 +643,34 @@ func GetTaskPluginOptions(c *gin.Context) { ...@@ -590,15 +643,34 @@ func GetTaskPluginOptions(c *gin.Context) {
continue continue
} }
seen[meta.Key] = true seen[meta.Key] = true
hasIcon := false
if layer == 0 {
if row, rowErr := model.GetTaskPluginVersion(meta.Key, ""); rowErr == nil {
hasIcon = row.HasIcon()
}
} else {
_, _, hasIcon = plugins.Icon(meta.Key)
}
options = append(options, gin.H{ options = append(options, gin.H{
"key": meta.Key, "key": meta.Key,
"name": meta.Name, "name": meta.Name,
"icon": meta.Icon,
"hasIcon": hasIcon,
"baseUrl": meta.BaseURL,
"sortPriority": meta.SortPriority,
"website": meta.Website,
"models": meta.Models, "models": meta.Models,
"usageSchema": meta.UsageSchema, "usageSchema": meta.UsageSchema,
}) })
} }
} }
sort.Slice(options, func(i, j int) bool { return options[i]["key"].(string) < options[j]["key"].(string) }) sort.Slice(options, func(i, j int) bool {
left, right := options[i]["sortPriority"].(int), options[j]["sortPriority"].(int)
if left != right {
return left > right
}
return options[i]["key"].(string) < options[j]["key"].(string)
})
common.ApiSuccess(c, options) common.ApiSuccess(c, options)
} }
......
package controller package controller
import ( import (
"bytes"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
...@@ -356,11 +358,13 @@ func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) { ...@@ -356,11 +358,13 @@ func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) {
assert.Equal(t, "kling", item.Meta.Key) assert.Equal(t, "kling", item.Meta.Key)
} }
func TestGetTaskPluginOptionsIncludesUsageSchema(t *testing.T) { func TestGetTaskPluginOptionsIncludesUsageSchemaIconAndBaseURL(t *testing.T) {
setupTaskPluginControllerTest(t)
const key = "usage-options-probe" const key = "usage-options-probe"
source := ` source := `
export const meta = { export const meta = {
apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"}, apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"},
icon: "text:UO", baseUrl: "http://localhost:9000/",
models: ["usage-options-model"], fetchMode: "per_task", models: ["usage-options-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}} usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}}
}; };
...@@ -383,6 +387,8 @@ export function parseTaskResult() { return {}; } ...@@ -383,6 +387,8 @@ export function parseTaskResult() { return {}; }
Success bool `json:"success"` Success bool `json:"success"`
Data []struct { Data []struct {
Key string `json:"key"` Key string `json:"key"`
Icon string `json:"icon"`
BaseURL string `json:"baseUrl"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"` UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
} `json:"data"` } `json:"data"`
} }
...@@ -394,6 +400,8 @@ export function parseTaskResult() { return {}; } ...@@ -394,6 +400,8 @@ export function parseTaskResult() { return {}; }
} }
assert.Equal(t, "second", option.UsageSchema["seconds"].Unit) assert.Equal(t, "second", option.UsageSchema["seconds"].Unit)
assert.Equal(t, "Generated media duration.", option.UsageSchema["seconds"].Description["en"]) assert.Equal(t, "Generated media duration.", option.UsageSchema["seconds"].Description["en"])
assert.Equal(t, "text:UO", option.Icon)
assert.Equal(t, "http://localhost:9000", option.BaseURL, "the drawer prefills the normalized plugin default")
return return
} }
t.Fatal("task plugin option not found") t.Fatal("task plugin option not found")
...@@ -935,11 +943,13 @@ export function parseTaskResult() { return {}; } ...@@ -935,11 +943,13 @@ export function parseTaskResult() { return {}; }
} }
func TestDeletePureFactoryPluginIsRejected(t *testing.T) { func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
for _, query := range []string{"", "?force=true"} {
t.Run(query, func(t *testing.T) {
setupTaskPluginControllerTest(t) setupTaskPluginControllerTest(t)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder) context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}} context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}}
context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0", nil) context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0"+query, nil)
DeleteTaskPluginVersion(context) DeleteTaskPluginVersion(context)
...@@ -947,6 +957,8 @@ func TestDeletePureFactoryPluginIsRejected(t *testing.T) { ...@@ -947,6 +957,8 @@ func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted") assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted")
_, ok := jsplugin.DefaultRegistry.Get("kling") _, ok := jsplugin.DefaultRegistry.Get("kling")
assert.True(t, ok) assert.True(t, ok)
})
}
} }
func TestUploadTaskPluginSourceSha256(t *testing.T) { func TestUploadTaskPluginSourceSha256(t *testing.T) {
...@@ -1118,3 +1130,106 @@ func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) { ...@@ -1118,3 +1130,106 @@ func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) {
}) })
} }
} }
func TestTaskPluginDisplayOrderAndMetadata(t *testing.T) {
setupTaskPluginControllerTest(t)
const website = "https://example.com/plugins"
keys := []string{"display-low", "display-zero", "display-beta", "display-alpha"}
priorities := []int{-10, 0, 50, 50}
for i, key := range keys {
source := strings.Replace(taskPluginControllerTestSource(key, "1.0.0"), "apiVersion: 1,", fmt.Sprintf("apiVersion: 1, sortPriority: %d, website: %q,", priorities[i], website), 1)
_, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
require.NoError(t, err)
cleanupTaskPluginControllerRuntime(t, key)
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: source, Enabled: true}))
}
for _, endpoint := range []string{"list", "options"} {
t.Run(endpoint, func(t *testing.T) {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/", nil)
var metas []jsplugin.Meta
if endpoint == "list" {
ListTaskPlugins(context)
var response struct {
Success bool
Data []taskPluginListItem
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
for _, item := range response.Data {
metas = append(metas, item.Meta)
}
} else {
GetTaskPluginOptions(context)
var response struct {
Success bool
Data []jsplugin.Meta
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
metas = response.Data
}
var ordered []string
for _, meta := range metas {
if strings.HasPrefix(meta.Key, "display-") {
ordered = append(ordered, meta.Key)
assert.Equal(t, website, meta.Website)
}
}
assert.Equal(t, []string{"display-alpha", "display-beta", "display-zero", "display-low"}, ordered)
})
}
}
func TestUploadTaskPluginStoresSidecarIconAndServesIt(t *testing.T) {
setupTaskPluginControllerTest(t)
const key = "icon-sidecar"
source := taskPluginControllerTestSource(key, "1.0.0")
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
svgIcon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4"/></svg>`))
upload := func(icon string) *httptest.ResponseRecorder {
body, err := common.Marshal(map[string]any{"source": source, "icon": icon})
require.NoError(t, err)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
context.Request.Header.Set("Content-Type", "application/json")
UploadTaskPlugin(context)
return recorder
}
rejected := upload("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>`)))
assert.Contains(t, rejected.Body.String(), `"success":false`)
assert.Contains(t, rejected.Body.String(), "script")
_, err := model.GetTaskPluginVersion(key, "1.0.0")
require.ErrorIs(t, err, gorm.ErrRecordNotFound, "a rejected icon must not store the plugin")
accepted := upload(svgIcon)
require.Contains(t, accepted.Body.String(), `"success":true`)
assert.Contains(t, accepted.Body.String(), `"has_icon":true`)
assert.NotContains(t, accepted.Body.String(), "base64,", "icon bytes never travel inside detail JSON")
stored, err := model.GetTaskPluginVersion(key, "1.0.0")
require.NoError(t, err)
assert.Equal(t, svgIcon, stored.Icon)
item := listTaskPluginItem(t, key)
assert.True(t, item.HasIcon)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Params = gin.Params{{Key: "key", Value: key}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/"+key+"/icon", nil)
GetTaskPluginIcon(context)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "image/svg+xml", recorder.Header().Get("Content-Type"))
assert.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options"))
assert.Contains(t, recorder.Body.String(), "<circle")
missing := httptest.NewRecorder()
context, _ = gin.CreateTestContext(missing)
context.Params = gin.Params{{Key: "key", Value: "no-such-plugin"}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/no-such-plugin/icon", nil)
GetTaskPluginIcon(context)
assert.Equal(t, http.StatusNotFound, missing.Code)
}
...@@ -20,9 +20,9 @@ export type ProtocolClaim = ...@@ -20,9 +20,9 @@ export type ProtocolClaim =
| {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]} | {name: "openai_responses"; supports: readonly ResponsesMode[]; models?: readonly string[]}
| {name: "openai_video"; models?: readonly string[]}; | {name: "openai_video"; models?: readonly string[]};
export type LocalizedText = string | ({ en: string } & Record<string, string>); export type LocalizedText = string | ({ en: string } & Record<string, string>);
export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText}; export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText; enumLabels?: Readonly<Record<string, LocalizedText>>};
export type UsageExample = {label: string; facts: Readonly<Record<string, string | number>>}; export type UsageExample = {label: string; facts: Readonly<Record<string, string | number>>};
export interface Meta {apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}} export interface Meta {sortPriority?: number; website?: string; apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; baseUrl?: string; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly<Record<string, UsageFieldSchema>>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>} export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record<string, unknown>}
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]} export interface DriverContext {requestBody: unknown; requestHeaders: Readonly<Record<string, string>>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; data: unknown; state: unknown} export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; data: unknown; state: unknown}
......
...@@ -34,18 +34,28 @@ Enabled uploads pre-flight the candidate against the live routing generation and ...@@ -34,18 +34,28 @@ Enabled uploads pre-flight the candidate against the live routing generation and
`endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData, immediate?, state?}`; `clientResponse` is rejected. `endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData, immediate?, state?}`; `clientResponse` is rejected.
`icon` is an optional LobeHub icon name string (for example `Sora.Color`). The values `text` and `text:<label>` request a generated text avatar instead (label defaults to the first two characters of `name`). It is display-only and does not participate in routing, billing, or admission beyond type and length checks. `icon` is an optional LobeHub icon name string (for example `Sora.Color`). The values `text` and `text:<label>` request a generated text avatar instead (label defaults to the first two characters of `name`). Inline data URIs and remote URLs are rejected. `icon` is display-only and does not participate in routing or billing.
A plugin whose vendor has no LobeHub icon ships its logo as a sidecar file, `icon.svg` or `icon.png`, next to `plugin.js`; the manifest stays readable and the image never appears in source diffs. Built-in plugins embed the sidecar from `plugins/tasks/<key>/`. For uploads the admin UI reads the file and sends it in the upload request's separate `icon` field as `data:image/png;base64,...` or `data:image/svg+xml;base64,...`; marketplace indexes declare it as `iconFile.path` and the installer fetches it from the index's own origin. The gateway stores the logo apart from the source and serves it from `GET /api/plugin/task/:key/icon` (optionally `?version=`), which the UI renders only through `<img>`. Limits: 512 KiB; PNG must carry the PNG signature; SVG must be well-formed XML with an `svg` root and no `script` or `foreignObject` elements, event-handler attributes, DOCTYPE, `javascript:` values, or absolute `http(s)` references. Supply artwork legible on both light and dark backgrounds (an SVG may use a `prefers-color-scheme` media query).
`baseUrl` is an optional default upstream address for channels of type 61 ("Task Plugin"). When an administrator binds the plugin and leaves the channel Base URL empty, the host copies this value onto the channel before validation and records `base_url_source: plugin_default` in the channel audit event; the persisted channel value is what every later request uses, so a later plugin update that changes `baseUrl` does not move existing channels. The value must be an absolute `http` or `https` URL without credentials, query, or fragment; the host is lowercased and must be ASCII (use punycode), trailing slashes are stripped, and the normalized value must not exceed 191 characters (the width of the channel `base_url` column on MySQL). Private, loopback, and plain `http` addresses are allowed for self-hosted upstreams; the admin UI flags them before binding. `baseUrl` is ignored on legacy `channelTypes` channels, which keep the built-in per-type default. The default host is not implicitly added to `allowedHosts`: if the administrator points the channel at a different host, plugin requests to the author's default host are rejected.
`allowedHosts` lists extra hosts that plugin requests may target besides the channel base URL host. Each entry is `host` or `host:port` (IPv6 literals bracketed, for example `[::1]:8080`); schemes, paths, credentials, and queries are rejected. Entries are lowercased and must be unique after normalization. An entry with a port matches only requests to that port (default ports `80`/`443` match requests that omit them).
| Field | Type | Notes | | Field | Type | Notes |
|-------|------|-------| |-------|------|-------|
| `key` | string | Required. Canonical plugin id, ≤ 30 characters. | | `key` | string | Required. Canonical plugin id, ≤ 30 characters. |
| `name` | string | Required. Display name. | | `name` | string | Required. Display name. |
| `icon` | string | Optional LobeHub icon or `text` / `text:<label>`. ≤ 128 characters. | | `icon` | string | Optional LobeHub icon or `text` / `text:<label>`. ≤ 128 characters. Image logos ship as a sidecar `icon.svg` / `icon.png`, not in the manifest. |
| `description` | LocalizedText | Optional plugin summary. See LocalizedText. ≤ 512 runes per locale. | | `description` | LocalizedText | Optional plugin summary. See LocalizedText. ≤ 512 runes per locale. |
| `version` | string | Required semver. | | `version` | string | Required semver. |
| `sortPriority` | `number` | Optional signed 32-bit integer, default `0`. Higher values appear first; ties use ascending plugin key. Display only: does not change routing or override precedence. |
| `website` | `string` | Optional plugin website, independent of `author.url` and `baseUrl`. Empty is allowed; otherwise an absolute HTTPS URL with a valid ASCII hostname (use punycode for internationalized domains), without credentials, whitespace, control characters, or backslashes. Paths, queries, and fragments are allowed. |
| `author` | `{name, url?}` | Required name; `url`, when present, must be an absolute HTTP(S) URL. | | `author` | `{name, url?}` | Required name; `url`, when present, must be an absolute HTTP(S) URL. |
| `baseUrl` | string | Optional default Base URL for type-61 Task Plugin channels. Absolute `http(s)` URL, no credentials/query/fragment, ASCII host, trailing slash stripped, ≤ 191 characters. |
| `allowedHosts` | string[] | Optional extra request hosts, `host` or `host:port`. No schemes, paths, or credentials. |
`channelTypes` lists the legacy channel types this plugin's driver can drive (for example, sora declares `[55, 1]` because the same OpenAI-type base URL and bearer key serve both chat and video). Every entry equally participates in channel selection, historical `Task.Platform` matching, and the `byChannelType` routing index; the same type value may not appear on two plugins. Third-party plugins normally omit `channelTypes` and live on type-59 "Task Plugin" channels bound by `task_plugin_key`. The previous split identity/compatibility field names are rejected. `channelTypes` lists the legacy channel types this plugin's driver can drive (for example, sora declares `[55, 1]` because the same OpenAI-type base URL and bearer key serve both chat and video). Every entry equally participates in channel selection, historical `Task.Platform` matching, and the `byChannelType` routing index; the same type value may not appear on two plugins. Third-party plugins normally omit `channelTypes` and live on type-61 "Task Plugin" channels bound by `task_plugin_key`. The previous split identity/compatibility field names are rejected.
Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `token`, or `credit`. Boolean fields declare `{type: "boolean"}`. Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `token`, or `credit`. Boolean fields declare `{type: "boolean"}`.
...@@ -55,6 +65,25 @@ Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `to ...@@ -55,6 +65,25 @@ Numeric `usageSchema` fields declare a host-owned unit of `second`, `count`, `to
`meta.description` and each `usageSchema` field `description` accept LocalizedText. A bare string is equivalent to `{en: <string>}`. A map must include a non-empty `en` value. The host normalizes both forms to a map; API responses always emit an object. `meta.description` and each `usageSchema` field `description` accept LocalizedText. A bare string is equivalent to `{en: <string>}`. A map must include a non-empty `en` value. The host normalizes both forms to a map; API responses always emit an object.
Enum fields may additionally declare `enumLabels`, a map from enum values to LocalizedText display names:
```js
video_input: {
enum: ["none", "video"],
description: {en: "Reference video input", zh: "参考视频输入"},
enumLabels: {
none: {en: "No reference video", zh: "无参考视频"},
video: {en: "With reference video", zh: "有参考视频"},
},
}
```
`enumLabels` is optional and may cover only some options. It is valid only on enum fields, and every key must exactly match a declared enum value. Each label follows the same locale normalization, required English fallback and 256-rune-per-locale limit as a field description. Labels are short phrases without trailing punctuation. They never change usage facts, expression conditions or values submitted by UI controls. Boolean fields use their description with a localized yes/no state and cannot declare `enumLabels`.
The UI selects the current language, then its primary language, then English. If no option label is provided, it displays the original enum value; a missing field description falls back to the field name. Labels are plugin data, not frontend translation keys.
This is an additive extension of `apiVersion: 1`. Updated gateways continue to load plugins without `enumLabels`; older gateways with strict property validation reject plugins that declare it.
```js ```js
description: "Video generation via the vendor API" description: "Video generation via the vendor API"
description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"} description: {en: "Video generation via the vendor API", zh: "通过厂商接口生成视频"}
...@@ -69,6 +98,24 @@ Rules: ...@@ -69,6 +98,24 @@ Rules:
- The frontend resolves a locale with exact tag → primary subtag → `en` (for example `zh-TW``zh``en`). - The frontend resolves a locale with exact tag → primary subtag → `en` (for example `zh-TW``zh``en`).
- Description copy in any language must not include vendor currency prices. The same prohibition applies to `usageExamples` labels. - Description copy in any language must not include vendor currency prices. The same prohibition applies to `usageExamples` labels.
### Description writing and translation conventions
Descriptions are short, user-facing phrases without trailing punctuation in any language. Keep translations equivalent in meaning. For `usageSchema` fields, use the following wording:
| Field meaning | Wording | English example | Chinese example |
| --- | --- | --- | --- |
| Numeric billing quantity | Billing subject + unit price | Song generation unit price | 生成歌曲单价 |
| Action | Action phrase | Generate songs | 生成歌曲 |
| Boolean | Whether a state is enabled or something is present | Whether audio is generated / Reference video present | 是否生成音频 / 存在参考视频 |
| Other enum condition | Short condition name | Output video resolution | 输出视频分辨率 |
- A shared description must cover all supported actions. For Suno, `clips.description` is `Song or lyrics generation unit price` / `生成歌曲或歌词单价`, and `action.description` is `Generate songs or lyrics` / `生成歌曲或歌词`.
- Boolean descriptions name the affirmative state represented by `true`; do not add a question mark or invert the meaning of the field.
- Preserve distinctions such as input versus output. Units belong in `unit`; do not repeat unit explanations or numeric prices in the description.
- Keep protocol limits, usage sources, estimation and settlement details in code comments or technical documentation, rather than in display descriptions.
- For example, replace `Requested video duration in seconds.` / `请求的视频时长,单位为秒。` with `Video generation unit price` / `视频生成单价`; replace `Whether audio is generated. Default true.` / `是否生成音频。默认为 true。` with `Whether audio is generated` / `是否生成音频`.
- These are authoring conventions for plugin metadata, not additional runtime validation rules. Existing `LocalizedText` wire types, usage quantities, enum values and billing semantics are unchanged; a numeric field still contains usage, not a price.
## Request body ## Request body
Every decoder receives one host-parsed body: Every decoder receives one host-parsed body:
...@@ -175,3 +222,5 @@ The host classifies the HTTP status before trusting a non-terminal parse: ...@@ -175,3 +222,5 @@ The host classifies the HTTP status before trusting a non-terminal parse:
| Other 4xx | Call the parse hook with `response.status`. A still-non-terminal result is unrecognized and increments `PollFailures`. | | Other 4xx | Call the parse hook with `response.status`. A still-non-terminal result is unrecognized and increments `PollFailures`. |
A valid 2xx non-terminal parse resets `PollFailures` to 0. After `TASK_POLL_MAX_FAILURES` (default 20) consecutive failures the task becomes `FAILURE` and follows the existing refund chain. The 24h `TASK_TIMEOUT_MINUTES` sweep remains the outer deadline. A valid 2xx non-terminal parse resets `PollFailures` to 0. After `TASK_POLL_MAX_FAILURES` (default 20) consecutive failures the task becomes `FAILURE` and follows the existing refund chain. The 24h `TASK_TIMEOUT_MINUTES` sweep remains the outer deadline.
Marketplace index v1 plugin entries also accept optional `sortPriority` and `website` fields. Each source is sorted independently by descending priority and ascending key. Missing or invalid priorities fall back to `0`; invalid websites are hidden. Installed plugins and channel binding options use plugin metadata. Website links open only on user interaction; the host does not fetch them.
...@@ -6,8 +6,11 @@ ...@@ -6,8 +6,11 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["apiVersion", "key", "name", "version", "author", "models", "fetchMode"], "required": ["apiVersion", "key", "name", "version", "author", "models", "fetchMode"],
"properties": { "properties": {
"sortPriority": {"type": "integer", "minimum": -2147483648, "maximum": 2147483647, "default": 0},
"website": {"oneOf": [{"const": ""}, {"type": "string", "format": "uri", "pattern": "^[Hh][Tt][Tt][Pp][Ss]://[^/@?#\\s]+(?:[/?#]|$)"}], "description": "Absolute HTTPS URL with a valid ASCII hostname (use punycode for internationalized domains) and no credentials; validated by the host."},
"apiVersion": {"const": 1}, "key": {"type": "string"}, "name": {"type": "string"}, "icon": {"type": "string", "maxLength": 128}, "description": {"$ref": "#/$defs/localizedText"}, "version": {"type": "string"}, "apiVersion": {"const": 1}, "key": {"type": "string"}, "name": {"type": "string"}, "icon": {"type": "string", "maxLength": 128}, "description": {"$ref": "#/$defs/localizedText"}, "version": {"type": "string"},
"author": {"type": "object", "required": ["name"], "additionalProperties": false, "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}}, "author": {"type": "object", "required": ["name"], "additionalProperties": false, "properties": {"name": {"type": "string"}, "url": {"type": "string", "format": "uri"}}},
"baseUrl": {"type": "string", "format": "uri", "pattern": "^https?://", "maxLength": 191},
"channelTypes": {"type": "array", "uniqueItems": true, "items": {"type": "integer", "minimum": 1}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "channelTypes": {"type": "array", "uniqueItems": true, "items": {"type": "integer", "minimum": 1}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
"fetchMode": {"enum": ["per_task", "batch"]}, "allowedHosts": {"type": "array", "uniqueItems": true, "items": {"type": "string"}}, "fetchMode": {"enum": ["per_task", "batch"]}, "allowedHosts": {"type": "array", "uniqueItems": true, "items": {"type": "string"}},
"protocols": {"type": "array", "uniqueItems": true, "items": {"oneOf": [ "protocols": {"type": "array", "uniqueItems": true, "items": {"oneOf": [
...@@ -15,9 +18,35 @@ ...@@ -15,9 +18,35 @@
{"type": "object", "additionalProperties": false, "required": ["name", "supports"], "properties": {"name": {"const": "openai_responses"}, "supports": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stream", "sync", "background"]}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}, {"type": "object", "additionalProperties": false, "required": ["name", "supports"], "properties": {"name": {"const": "openai_responses"}, "supports": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": ["stream", "sync", "background"]}}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}},
{"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"const": "openai_video"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}} {"type": "object", "additionalProperties": false, "required": ["name"], "properties": {"name": {"const": "openai_video"}, "models": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}
]}}, ]}},
"routes": {"type": "array", "items": {"$ref": "#/$defs/route"}}, "usageSchema": {"type": "object", "additionalProperties": {"type": "object", "properties": {"description": {"$ref": "#/$defs/localizedText"}}}}, "usageExamples": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "facts"], "properties": {"label": {"type": "string"}, "facts": {"type": "object"}}}}, "auth": {} "routes": {"type": "array", "items": {"$ref": "#/$defs/route"}}, "usageSchema": {"type": "object", "additionalProperties": {"$ref": "#/$defs/usageField"}}, "usageExamples": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["label", "facts"], "properties": {"label": {"type": "string"}, "facts": {"type": "object"}}}}, "auth": {}
}, },
"$defs": { "$defs": {
"usageField": {
"type": "object",
"additionalProperties": false,
"properties": {
"type": {"enum": ["number", "boolean"]},
"unit": {"enum": ["second", "count", "token", "credit"]},
"enum": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}},
"description": {"$ref": "#/$defs/usageLabel"},
"enumLabels": {
"type": "object",
"additionalProperties": {"$ref": "#/$defs/usageLabel"},
"description": "Optional display names keyed by declared enum values; the host validates membership. Missing entries display the original value."
}
},
"oneOf": [
{"required": ["enum"], "not": {"anyOf": [{"required": ["type"]}, {"required": ["unit"]}]}},
{"required": ["type", "unit"], "properties": {"type": {"const": "number"}}, "not": {"anyOf": [{"required": ["enum"]}, {"required": ["enumLabels"]}]}},
{"required": ["type"], "properties": {"type": {"const": "boolean"}}, "not": {"anyOf": [{"required": ["unit"]}, {"required": ["enum"]}, {"required": ["enumLabels"]}]}}
]
},
"usageLabel": {
"allOf": [
{"$ref": "#/$defs/localizedText"},
{"if": {"type": "string"}, "then": {"maxLength": 256}, "else": {"additionalProperties": {"maxLength": 256}}}
]
},
"localizedText": { "localizedText": {
"oneOf": [ "oneOf": [
{"type": "string", "minLength": 1}, {"type": "string", "minLength": 1},
......
...@@ -429,6 +429,13 @@ func updatePricing() { ...@@ -429,6 +429,13 @@ func updatePricing() {
for key, field := range plugin.Meta.UsageSchema { for key, field := range plugin.Meta.UsageSchema {
field.Enum = append([]string(nil), field.Enum...) field.Enum = append([]string(nil), field.Enum...)
field.Description = maps.Clone(field.Description) field.Description = maps.Clone(field.Description)
if field.EnumLabels != nil {
labels := make(map[string]jsplugin.LocalizedText, len(field.EnumLabels))
for value, label := range field.EnumLabels {
labels[value] = maps.Clone(label)
}
field.EnumLabels = labels
}
pricing.BillingUsageSchema[key] = field pricing.BillingUsageSchema[key] = field
} }
if len(plugin.Meta.UsageExamples) > 0 { if len(plugin.Meta.UsageExamples) > 0 {
......
...@@ -30,7 +30,8 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin ...@@ -30,7 +30,8 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin
resetPricingEndpointTestTables(t) resetPricingEndpointTestTables(t)
const pluginKey = "pricing-usage-probe" const pluginKey = "pricing-usage-probe"
initialSource := pricingUsagePluginSource("1.0.0", `{ initialSource := pricingUsagePluginSource("1.0.0", `{
seconds: {type: "number", unit: "second", description: "Estimated duration."} seconds: {type: "number", unit: "second", description: "Estimated duration."},
action: {enum:["video"],enumLabels:{video:{en:"Generate video",zh:"生成视频"}}}
}`) }`)
_, err := jsplugin.DefaultRegistry.Register(initialSource, jsplugin.Options{}) _, err := jsplugin.DefaultRegistry.Register(initialSource, jsplugin.Options{})
require.NoError(t, err) require.NoError(t, err)
...@@ -45,6 +46,7 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin ...@@ -45,6 +46,7 @@ func TestPricingCarriesTaskUsageSchemaAndRefreshesWithPluginGeneration(t *testin
require.Contains(t, initialPricing, "ordinary-model") require.Contains(t, initialPricing, "ordinary-model")
assert.Equal(t, "second", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Unit) assert.Equal(t, "second", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Unit)
assert.Equal(t, "Estimated duration.", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"]) assert.Equal(t, "Estimated duration.", initialPricing["pricing-usage-model"].BillingUsageSchema["seconds"].Description["en"])
assert.Equal(t, "生成视频", initialPricing["pricing-usage-model"].BillingUsageSchema["action"].EnumLabels["video"]["zh"])
assert.Nil(t, initialPricing["ordinary-model"].BillingUsageSchema) assert.Nil(t, initialPricing["ordinary-model"].BillingUsageSchema)
updatedSource := pricingUsagePluginSource("1.1.0", `{ updatedSource := pricingUsagePluginSource("1.1.0", `{
......
...@@ -41,12 +41,25 @@ type TaskPlugin struct { ...@@ -41,12 +41,25 @@ type TaskPlugin struct {
Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"` Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"`
Source string `json:"source" gorm:"type:text;not null"` Source string `json:"source" gorm:"type:text;not null"`
SourceHash string `json:"source_hash" gorm:"size:64;not null"` SourceHash string `json:"source_hash" gorm:"size:64;not null"`
// Icon is the plugin logo shipped as a sidecar icon.svg / icon.png next to
// plugin.js, stored as a data URI so one column carries both the media
// type and the bytes. It never travels inside list or detail JSON; the UI
// loads it through GET /api/plugin/task/:key/icon. size matches the
// 512 KiB icon cap and makes GORM emit mediumtext on MySQL (a bare TEXT
// column there holds only 64 KiB), varchar(524288) on PostgreSQL, and text
// on SQLite.
Icon string `json:"-" gorm:"size:524288"`
Enabled bool `json:"enabled" gorm:"not null"` Enabled bool `json:"enabled" gorm:"not null"`
Active bool `json:"active" gorm:"not null;index"` Active bool `json:"active" gorm:"not null;index"`
CreatedAt int64 `json:"created_at" gorm:"not null"` CreatedAt int64 `json:"created_at" gorm:"not null"`
Remark string `json:"remark" gorm:"type:text"` Remark string `json:"remark" gorm:"type:text"`
} }
// HasIcon reports whether this version ships a logo.
func (plugin TaskPlugin) HasIcon() bool {
return plugin.Icon != ""
}
func SaveTaskPlugin(plugin *TaskPlugin) error { func SaveTaskPlugin(plugin *TaskPlugin) error {
return DB.Transaction(func(tx *gorm.DB) error { return DB.Transaction(func(tx *gorm.DB) error {
var existing TaskPlugin var existing TaskPlugin
...@@ -55,7 +68,12 @@ func SaveTaskPlugin(plugin *TaskPlugin) error { ...@@ -55,7 +68,12 @@ func SaveTaskPlugin(plugin *TaskPlugin) error {
if existing.SourceHash != plugin.SourceHash { if existing.SourceHash != plugin.SourceHash {
return errors.New("plugin key and version already exist with different source") return errors.New("plugin key and version already exist with different source")
} }
if err = tx.Model(&existing).Updates(map[string]any{"enabled": plugin.Enabled, "remark": plugin.Remark}).Error; err != nil { updates := map[string]any{"enabled": plugin.Enabled, "remark": plugin.Remark}
if plugin.Icon != "" {
updates["icon"] = plugin.Icon
existing.Icon = plugin.Icon
}
if err = tx.Model(&existing).Updates(updates).Error; err != nil {
return err return err
} }
existing.Enabled = plugin.Enabled existing.Enabled = plugin.Enabled
......
...@@ -354,6 +354,9 @@ func TestValidateRequestURL(t *testing.T) { ...@@ -354,6 +354,9 @@ func TestValidateRequestURL(t *testing.T) {
{name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"}, {name: "same host", requestURL: "https://api.example.com/v1/task", baseURL: "https://api.example.com/v1"},
{name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"}, {name: "default port", requestURL: "https://api.example.com:443/v1/task", baseURL: "https://api.example.com"},
{name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}}, {name: "approved host", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com"}},
{name: "approved host with port", requestURL: "http://192.168.1.10:8080/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}},
{name: "approved host with explicit default port", requestURL: "https://upload.example.com/task", baseURL: "https://api.example.com", allowedHosts: []string{"upload.example.com:443"}},
{name: "approved host port mismatch", requestURL: "http://192.168.1.10:9000/task", baseURL: "http://192.168.1.10:8000", allowedHosts: []string{"192.168.1.10:8080"}, wantError: "not allowed"},
{name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"}, {name: "subdomain is not implicit", requestURL: "https://evil.api.example.com/task", baseURL: "https://api.example.com", wantError: "not allowed"},
{name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"}, {name: "userinfo trick", requestURL: "https://api.example.com@evil.example/task", baseURL: "https://api.example.com", wantError: "not allowed"},
{name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"}, {name: "relative URL", requestURL: "/v1/task", baseURL: "https://api.example.com", wantError: "absolute"},
......
package jsplugin
import (
"bytes"
"encoding/base64"
"encoding/xml"
"fmt"
"io"
"strings"
"unicode"
)
// MaxIconDataURIBytes bounds an uploaded plugin logo. It is deliberately
// generous: the plugin source itself is capped at 1 MiB, so this only keeps a
// logo from dwarfing the plugin it decorates.
const MaxIconDataURIBytes = 512 * 1024
var pngSignature = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
// DecodeIconDataURI parses a plugin logo shipped as a sidecar icon.svg or
// icon.png file and re-encoded by the uploader as
// data:image/png;base64,... or data:image/svg+xml;base64,.... It returns the
// media type and the raw image bytes. The admin UI renders logos only through
// <img>, which already blocks scripts and external loads; the SVG checks here
// are defense in depth so a hostile payload is rejected at upload instead of
// being stored.
func DecodeIconDataURI(icon string) (string, []byte, error) {
if len(icon) > MaxIconDataURIBytes {
return "", nil, fmt.Errorf("plugin icon must not exceed %d bytes", MaxIconDataURIBytes)
}
mediaType, payload, ok := strings.Cut(strings.TrimPrefix(icon, "data:"), ";base64,")
if !strings.HasPrefix(icon, "data:") || !ok || (mediaType != "image/png" && mediaType != "image/svg+xml") {
return "", nil, fmt.Errorf("plugin icon must be data:image/png;base64,... or data:image/svg+xml;base64,...")
}
decoded, err := base64.StdEncoding.Strict().DecodeString(payload)
if err != nil {
return "", nil, fmt.Errorf("plugin icon payload is not valid base64")
}
if err := ValidateIconImage(mediaType, decoded); err != nil {
return "", nil, err
}
return mediaType, decoded, nil
}
// ValidateIconImage checks raw PNG or SVG bytes for a plugin logo. PNG must
// carry the PNG signature. SVG must be well-formed XML rooted at svg without
// script or foreignObject elements, event-handler attributes, directives,
// javascript: values, or absolute http(s) references.
func ValidateIconImage(mediaType string, data []byte) error {
if mediaType == "image/png" {
if !bytes.HasPrefix(data, pngSignature) {
return fmt.Errorf("plugin icon PNG payload is not a PNG image")
}
return nil
}
if mediaType != "image/svg+xml" {
return fmt.Errorf("plugin icon must be image/png or image/svg+xml")
}
decoder := xml.NewDecoder(bytes.NewReader(data))
rootSeen := false
styleDepth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("plugin icon SVG is not well-formed XML")
}
switch element := token.(type) {
case xml.Directive:
return fmt.Errorf("plugin icon SVG must not contain a DOCTYPE or other directives")
case xml.ProcInst:
if element.Target != "xml" {
return fmt.Errorf("plugin icon SVG must not contain processing instructions")
}
case xml.StartElement:
local := strings.ToLower(element.Name.Local)
if !rootSeen {
if local != "svg" {
return fmt.Errorf("plugin icon SVG root element must be svg")
}
rootSeen = true
}
if local == "script" || local == "foreignobject" {
return fmt.Errorf("plugin icon SVG must not contain %s elements", element.Name.Local)
}
if local == "style" {
styleDepth++
}
for _, attr := range element.Attr {
if attr.Name.Space == "xmlns" || (attr.Name.Space == "" && attr.Name.Local == "xmlns") {
continue
}
if strings.HasPrefix(strings.ToLower(attr.Name.Local), "on") {
return fmt.Errorf("plugin icon SVG must not contain event handler attributes")
}
if svgValueReferencesExternal(attr.Value) {
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
}
}
case xml.EndElement:
if strings.ToLower(element.Name.Local) == "style" && styleDepth > 0 {
styleDepth--
}
case xml.CharData:
if styleDepth > 0 && (svgValueReferencesExternal(string(element)) || strings.Contains(strings.ToLower(string(element)), "@import")) {
return fmt.Errorf("plugin icon SVG must not reference scripts or external resources")
}
}
}
if !rootSeen {
return fmt.Errorf("plugin icon SVG root element must be svg")
}
return nil
}
func svgValueReferencesExternal(value string) bool {
compact := strings.Map(func(character rune) rune {
if unicode.IsSpace(character) || unicode.IsControl(character) {
return -1
}
return unicode.ToLower(character)
}, value)
return strings.Contains(compact, "javascript:") || strings.Contains(compact, "http://") || strings.Contains(compact, "https://")
}
...@@ -6,10 +6,12 @@ import ( ...@@ -6,10 +6,12 @@ import (
"fmt" "fmt"
"maps" "maps"
"math" "math"
"net"
"net/url" "net/url"
"regexp" "regexp"
"slices" "slices"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
...@@ -32,6 +34,8 @@ const ( ...@@ -32,6 +34,8 @@ const (
maxUsageFieldDescriptionRunes = 256 maxUsageFieldDescriptionRunes = 256
) )
var websiteHostLabelPattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
var pluginKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) var pluginKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
var pluginVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) var pluginVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`)
var localeTagPattern = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`) var localeTagPattern = regexp.MustCompile(`^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$`)
...@@ -75,6 +79,8 @@ func (t *LocalizedText) UnmarshalJSON(data []byte) error { ...@@ -75,6 +79,8 @@ func (t *LocalizedText) UnmarshalJSON(data []byte) error {
} }
type Meta struct { type Meta struct {
SortPriority int `json:"sortPriority,omitempty"`
Website string `json:"website,omitempty"`
APIVersion int `json:"apiVersion"` APIVersion int `json:"apiVersion"`
Key string `json:"key"` Key string `json:"key"`
Name string `json:"name"` Name string `json:"name"`
...@@ -82,6 +88,7 @@ type Meta struct { ...@@ -82,6 +88,7 @@ type Meta struct {
Description LocalizedText `json:"description,omitempty"` Description LocalizedText `json:"description,omitempty"`
Version string `json:"version"` Version string `json:"version"`
Author AuthorMeta `json:"author"` Author AuthorMeta `json:"author"`
BaseURL string `json:"baseUrl,omitempty"`
ChannelTypes []int `json:"channelTypes,omitempty"` ChannelTypes []int `json:"channelTypes,omitempty"`
Models []string `json:"models"` Models []string `json:"models"`
FetchMode string `json:"fetchMode"` FetchMode string `json:"fetchMode"`
...@@ -127,6 +134,7 @@ type UsageFieldSchema struct { ...@@ -127,6 +134,7 @@ type UsageFieldSchema struct {
Unit string `json:"unit,omitempty"` Unit string `json:"unit,omitempty"`
Enum []string `json:"enum,omitempty"` Enum []string `json:"enum,omitempty"`
Description LocalizedText `json:"description,omitempty"` Description LocalizedText `json:"description,omitempty"`
EnumLabels map[string]LocalizedText `json:"enumLabels,omitempty"`
} }
type LoadedPlugin struct { type LoadedPlugin struct {
...@@ -807,6 +815,13 @@ func cloneMeta(meta Meta) Meta { ...@@ -807,6 +815,13 @@ func cloneMeta(meta Meta) Meta {
if field.Description != nil { if field.Description != nil {
field.Description = maps.Clone(field.Description) field.Description = maps.Clone(field.Description)
} }
if field.EnumLabels != nil {
labels := make(map[string]LocalizedText, len(field.EnumLabels))
for value, label := range field.EnumLabels {
labels[value] = maps.Clone(label)
}
field.EnumLabels = labels
}
usageSchema[key] = field usageSchema[key] = field
} }
meta.UsageSchema = usageSchema meta.UsageSchema = usageSchema
...@@ -896,7 +911,7 @@ func decodeMeta(value any) (Meta, error) { ...@@ -896,7 +911,7 @@ func decodeMeta(value any) (Meta, error) {
} }
for field := range object { for field := range object {
switch field { switch field {
case "apiVersion", "key", "name", "icon", "description", "version", "author", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "auth", "endpoints", "submitPaths", "actions": case "sortPriority", "website", "apiVersion", "key", "name", "icon", "description", "version", "author", "baseUrl", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "auth", "endpoints", "submitPaths", "actions":
default: default:
return Meta{}, fmt.Errorf("plugin meta has unknown field %q", field) return Meta{}, fmt.Errorf("plugin meta has unknown field %q", field)
} }
...@@ -916,6 +931,12 @@ func decodeMeta(value any) (Meta, error) { ...@@ -916,6 +931,12 @@ func decodeMeta(value any) (Meta, error) {
if meta.Icon, err = stringMetaField(object, "icon"); err != nil { if meta.Icon, err = stringMetaField(object, "icon"); err != nil {
return Meta{}, err return Meta{}, err
} }
if meta.SortPriority, err = integerMetaField(object, "sortPriority"); err != nil {
return Meta{}, err
}
if meta.Website, err = stringMetaField(object, "website"); err != nil {
return Meta{}, err
}
meta.Icon = strings.TrimSpace(meta.Icon) meta.Icon = strings.TrimSpace(meta.Icon)
if meta.Description, err = localizedTextMetaField(object, "description", maxMetaDescriptionRunes); err != nil { if meta.Description, err = localizedTextMetaField(object, "description", maxMetaDescriptionRunes); err != nil {
return Meta{}, err return Meta{}, err
...@@ -941,6 +962,9 @@ func decodeMeta(value any) (Meta, error) { ...@@ -941,6 +962,9 @@ func decodeMeta(value any) (Meta, error) {
return Meta{}, fmt.Errorf("plugin meta author field %q must be a string", "url") return Meta{}, fmt.Errorf("plugin meta author field %q must be a string", "url")
} }
} }
if meta.BaseURL, err = stringMetaField(object, "baseUrl"); err != nil {
return Meta{}, err
}
if _, exists := object["channelType"]; exists { if _, exists := object["channelType"]; exists {
return Meta{}, fmt.Errorf("plugin meta channelType is no longer supported; declare channelTypes instead") return Meta{}, fmt.Errorf("plugin meta channelType is no longer supported; declare channelTypes instead")
} }
...@@ -1034,6 +1058,38 @@ func ValidateV1Meta(meta Meta) error { ...@@ -1034,6 +1058,38 @@ func ValidateV1Meta(meta Meta) error {
} }
func normalizeV1Meta(meta *Meta) error { func normalizeV1Meta(meta *Meta) error {
if meta.SortPriority < math.MinInt32 || meta.SortPriority > math.MaxInt32 {
return fmt.Errorf("plugin meta sortPriority must be a signed 32-bit integer")
}
meta.Website = strings.TrimSpace(meta.Website)
if meta.Website != "" {
parsed, err := url.Parse(meta.Website)
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil || parsed.Opaque != "" {
return fmt.Errorf("plugin meta website must be an absolute HTTPS URL without credentials")
}
for _, character := range meta.Website {
if unicode.IsSpace(character) || unicode.IsControl(character) || character == '\\' {
return fmt.Errorf("plugin meta website must not contain whitespace, control characters, or backslashes")
}
}
host := strings.TrimSuffix(parsed.Hostname(), ".")
if net.ParseIP(host) == nil {
if len(host) > 253 || host == "" {
return fmt.Errorf("plugin meta website must have a valid hostname")
}
for _, label := range strings.Split(host, ".") {
if !websiteHostLabelPattern.MatchString(label) {
return fmt.Errorf("plugin meta website must have a valid ASCII hostname; use punycode for internationalized domains")
}
}
}
if port := parsed.Port(); port != "" {
number, err := strconv.Atoi(port)
if err != nil || number < 0 || number > 65535 {
return fmt.Errorf("plugin meta website has an invalid port")
}
}
}
if meta.APIVersion != APIVersion1 { if meta.APIVersion != APIVersion1 {
return fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion) return fmt.Errorf("unsupported plugin apiVersion %d", meta.APIVersion)
} }
...@@ -1041,6 +1097,9 @@ func normalizeV1Meta(meta *Meta) error { ...@@ -1041,6 +1097,9 @@ func normalizeV1Meta(meta *Meta) error {
return fmt.Errorf("plugin meta name is required") return fmt.Errorf("plugin meta name is required")
} }
meta.Icon = strings.TrimSpace(meta.Icon) meta.Icon = strings.TrimSpace(meta.Icon)
if strings.HasPrefix(meta.Icon, "data:") || strings.Contains(meta.Icon, "://") {
return fmt.Errorf("plugin meta icon must be a LobeHub icon name or text; ship an image logo as an icon.svg or icon.png file next to plugin.js instead")
}
if meta.Icon != "" { if meta.Icon != "" {
if utf8.RuneCountInString(meta.Icon) > 128 { if utf8.RuneCountInString(meta.Icon) > 128 {
return fmt.Errorf("plugin meta icon must not exceed 128 characters") return fmt.Errorf("plugin meta icon must not exceed 128 characters")
...@@ -1065,6 +1124,14 @@ func normalizeV1Meta(meta *Meta) error { ...@@ -1065,6 +1124,14 @@ func normalizeV1Meta(meta *Meta) error {
return fmt.Errorf("plugin meta author url must be an absolute HTTP(S) URL") return fmt.Errorf("plugin meta author url must be an absolute HTTP(S) URL")
} }
} }
meta.BaseURL = strings.TrimSpace(meta.BaseURL)
if meta.BaseURL != "" {
normalized, err := normalizeMetaBaseURL(meta.BaseURL)
if err != nil {
return err
}
meta.BaseURL = normalized
}
if !pluginKeyPattern.MatchString(meta.Key) { if !pluginKeyPattern.MatchString(meta.Key) {
return fmt.Errorf("plugin meta key must match %s", pluginKeyPattern) return fmt.Errorf("plugin meta key must match %s", pluginKeyPattern)
} }
...@@ -1107,14 +1174,16 @@ func normalizeV1Meta(meta *Meta) error { ...@@ -1107,14 +1174,16 @@ func normalizeV1Meta(meta *Meta) error {
models[model] = struct{}{} models[model] = struct{}{}
} }
hosts := make(map[string]struct{}, len(meta.AllowedHosts)) hosts := make(map[string]struct{}, len(meta.AllowedHosts))
for _, host := range meta.AllowedHosts { for index, host := range meta.AllowedHosts {
if strings.TrimSpace(host) == "" || strings.ContainsAny(host, "/:?#") { normalized, err := normalizeAllowedHost(host)
return fmt.Errorf("plugin meta allowedHosts must contain hostnames without schemes, ports, or paths") if err != nil {
return err
} }
if _, exists := hosts[host]; exists { if _, exists := hosts[normalized]; exists {
return fmt.Errorf("plugin meta allowedHosts must be unique") return fmt.Errorf("plugin meta allowedHosts must be unique")
} }
hosts[host] = struct{}{} hosts[normalized] = struct{}{}
meta.AllowedHosts[index] = normalized
} }
routeKeys := make(map[string]struct{}, len(meta.Routes)) routeKeys := make(map[string]struct{}, len(meta.Routes))
for index := range meta.Routes { for index := range meta.Routes {
...@@ -1219,7 +1288,7 @@ func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) { ...@@ -1219,7 +1288,7 @@ func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
} }
for key := range fieldObject { for key := range fieldObject {
switch key { switch key {
case "type", "unit", "enum", "description": case "type", "unit", "enum", "description", "enumLabels":
default: default:
return nil, fmt.Errorf("plugin meta usageSchema field %q has unknown property %q", name, key) return nil, fmt.Errorf("plugin meta usageSchema field %q has unknown property %q", name, key)
} }
...@@ -1240,6 +1309,20 @@ func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) { ...@@ -1240,6 +1309,20 @@ func decodeUsageSchema(value any) (map[string]UsageFieldSchema, error) {
return nil, err return nil, err
} }
} }
if rawLabels, exists := fieldObject["enumLabels"]; exists {
labels, ok := rawLabels.(map[string]any)
if !ok {
return nil, fmt.Errorf("plugin meta usageSchema field %q enumLabels must be an object", name)
}
field.EnumLabels = make(map[string]LocalizedText, len(labels))
for value := range labels {
label, err := localizedTextMetaField(labels, value, maxUsageFieldDescriptionRunes)
if err != nil {
return nil, fmt.Errorf("plugin meta usageSchema field %q enumLabels: %w", name, err)
}
field.EnumLabels[value] = label
}
}
if err = validateUsageFieldSchema(name, field); err != nil { if err = validateUsageFieldSchema(name, field); err != nil {
return nil, err return nil, err
} }
...@@ -1252,6 +1335,9 @@ func validateUsageFieldSchema(name string, field UsageFieldSchema) error { ...@@ -1252,6 +1335,9 @@ func validateUsageFieldSchema(name string, field UsageFieldSchema) error {
if err := validateLocalizedText(field.Description, fmt.Sprintf("usageSchema field %q description", name), maxUsageFieldDescriptionRunes); err != nil { if err := validateLocalizedText(field.Description, fmt.Sprintf("usageSchema field %q description", name), maxUsageFieldDescriptionRunes); err != nil {
return err return err
} }
if field.EnumLabels != nil && field.Enum == nil {
return fmt.Errorf("plugin meta usageSchema field %q enumLabels requires enum", name)
}
if field.Enum != nil { if field.Enum != nil {
if field.Type != "" || field.Unit != "" { if field.Type != "" || field.Unit != "" {
return fmt.Errorf("plugin meta usageSchema field %q cannot combine enum with type or unit", name) return fmt.Errorf("plugin meta usageSchema field %q cannot combine enum with type or unit", name)
...@@ -1266,6 +1352,17 @@ func validateUsageFieldSchema(name string, field UsageFieldSchema) error { ...@@ -1266,6 +1352,17 @@ func validateUsageFieldSchema(name string, field UsageFieldSchema) error {
} }
values[value] = struct{}{} values[value] = struct{}{}
} }
for value, label := range field.EnumLabels {
if _, exists := values[value]; !exists {
return fmt.Errorf("plugin meta usageSchema field %q enumLabels has undeclared enum value %q", name, value)
}
if label == nil {
return fmt.Errorf("plugin meta usageSchema field %q enumLabels value %q must include a non-empty label", name, value)
}
if err := validateLocalizedText(label, fmt.Sprintf("usageSchema field %q enumLabels value %q", name, value), maxUsageFieldDescriptionRunes); err != nil {
return err
}
}
return nil return nil
} }
if field.Type == "boolean" { if field.Type == "boolean" {
...@@ -1588,6 +1685,94 @@ func integerSliceMetaField(object map[string]any, name string) ([]int, error) { ...@@ -1588,6 +1685,94 @@ func integerSliceMetaField(object map[string]any, name string) ([]int, error) {
return numbers, nil return numbers, nil
} }
// MaxMetaBaseURLLength bounds a normalized plugin default base URL. The value
// is persisted into channel.base_url, which the pinned MySQL driver creates as
// varchar(191); a longer default would store on SQLite and PostgreSQL but fail
// on MySQL.
const MaxMetaBaseURLLength = 191
// normalizeMetaBaseURL admits an absolute http(s) URL that a channel can adopt
// verbatim as its base URL: no credentials, query, or fragment, an ASCII
// lowercase host, and no trailing slash so plugins can concatenate paths.
func normalizeMetaBaseURL(raw string) (string, error) {
for _, character := range raw {
if unicode.IsSpace(character) || unicode.IsControl(character) {
return "", fmt.Errorf("plugin meta baseUrl must not contain whitespace or control characters")
}
}
if strings.ContainsAny(raw, "?#") {
return "", fmt.Errorf("plugin meta baseUrl must not contain a query or fragment")
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Opaque != "" || parsed.Host == "" || parsed.Hostname() == "" {
return "", fmt.Errorf("plugin meta baseUrl must be an absolute HTTP(S) URL")
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return "", fmt.Errorf("plugin meta baseUrl must use the http or https scheme")
}
if parsed.User != nil {
return "", fmt.Errorf("plugin meta baseUrl must not contain credentials")
}
hostname := parsed.Hostname()
for _, character := range hostname {
if character > unicode.MaxASCII {
return "", fmt.Errorf("plugin meta baseUrl host must be ASCII; use punycode for internationalized domains")
}
}
host := strings.ToLower(hostname)
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
if port := parsed.Port(); port != "" {
host += ":" + port
}
normalized := scheme + "://" + host + strings.TrimRight(parsed.EscapedPath(), "/")
if len(normalized) > MaxMetaBaseURLLength {
return "", fmt.Errorf("plugin meta baseUrl must not exceed %d characters", MaxMetaBaseURLLength)
}
return normalized, nil
}
// normalizeAllowedHost accepts "host" or "host:port" (IPv6 literals bracketed)
// and rejects schemes, paths, credentials, and queries so the entry stays a
// pure host match for ValidateRequestURL.
func normalizeAllowedHost(raw string) (string, error) {
entry := strings.TrimSpace(raw)
if entry == "" || strings.ContainsAny(entry, "/?#@") {
return "", fmt.Errorf("plugin meta allowedHosts must contain hostnames (optionally with a port) without schemes, paths, or credentials")
}
host, port := entry, ""
if splitHost, splitPort, err := net.SplitHostPort(entry); err == nil {
host, port = splitHost, splitPort
} else if strings.HasPrefix(entry, "[") && strings.HasSuffix(entry, "]") {
host = entry[1 : len(entry)-1]
}
if host == "" {
return "", fmt.Errorf("plugin meta allowedHosts must contain hostnames (optionally with a port) without schemes, paths, or credentials")
}
for _, character := range host {
if character > unicode.MaxASCII || unicode.IsSpace(character) || unicode.IsControl(character) {
return "", fmt.Errorf("plugin meta allowedHosts must contain ASCII hostnames; use punycode for internationalized domains")
}
}
host = strings.ToLower(host)
if strings.Contains(host, ":") {
if net.ParseIP(host) == nil {
return "", fmt.Errorf("plugin meta allowedHosts entries must be host or host:port; IPv6 literals must be bracketed")
}
host = "[" + host + "]"
}
if port != "" {
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
return "", fmt.Errorf("plugin meta allowedHosts port must be between 1 and 65535")
}
host += ":" + port
}
return host, nil
}
func stringMetaField(object map[string]any, name string) (string, error) { func stringMetaField(object map[string]any, name string) (string, error) {
value, exists := object[name] value, exists := object[name]
if !exists { if !exists {
......
package jsplugin package jsplugin
import ( import (
"encoding/base64"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
...@@ -326,6 +327,47 @@ func TestRegistryDecodesAndValidatesUsageSchema(t *testing.T) { ...@@ -326,6 +327,47 @@ func TestRegistryDecodesAndValidatesUsageSchema(t *testing.T) {
}) })
} }
func TestUsageEnumLabelsContract(t *testing.T) {
source := routingTestPluginSource("enum-labels", 0, `["model"]`, `usageSchema: {
mode: {enum: ["none", "video", "other"], enumLabels: {none: "No video", video: {EN: " With video ", zh: "有参考视频"}}}
},`, "")
registry := NewRegistry()
plugin, err := registry.Register(source, Options{})
require.NoError(t, err)
labels := plugin.Meta.UsageSchema["mode"].EnumLabels
assert.Equal(t, LocalizedText{"en": "No video"}, labels["none"])
assert.Equal(t, LocalizedText{"en": "With video", "zh": "有参考视频"}, labels["video"])
assert.NotContains(t, labels, "other")
wire, err := common.Marshal(plugin.Meta.UsageSchema)
require.NoError(t, err)
assert.JSONEq(t, `{"mode":{"enum":["none","video","other"],"enumLabels":{"none":{"en":"No video"},"video":{"en":"With video","zh":"有参考视频"}}}}`, string(wire))
snapshot := registry.Snapshot()
require.Len(t, snapshot.Override, 1)
snapshot.Override[0].UsageSchema["mode"].EnumLabels["video"]["en"] = "Changed"
delete(snapshot.Override[0].UsageSchema["mode"].EnumLabels, "none")
current := registry.Snapshot().Override[0].UsageSchema["mode"].EnumLabels
assert.Equal(t, "With video", current["video"]["en"])
assert.Contains(t, current, "none")
for _, tc := range []struct{ name, field, message string }{
{"unknown enum value", `{enum:["a"],enumLabels:{b:"B"}}`, "undeclared enum value"},
{"numeric field", `{type:"number",unit:"count",enumLabels:{}}`, "enumLabels requires enum"},
{"boolean field", `{type:"boolean",enumLabels:{true:"Yes"}}`, "enumLabels requires enum"},
{"missing English", `{enum:["a"],enumLabels:{a:{zh:"中文"}}}`, `must include a non-empty "en"`},
{"null map", `{enum:["a"],enumLabels:null}`, "enumLabels must be an object"},
{"null label", `{enum:["a"],enumLabels:{a:null}}`, "must be a string or object"},
{"empty label", `{enum:["a"],enumLabels:{a:" "}}`, "non-empty string"},
{"invalid locale", `{enum:["a"],enumLabels:{a:{en:"A",zh_CN:"甲"}}}`, "invalid locale"},
{"too long", `{enum:["a"],enumLabels:{a:"` + strings.Repeat("x", 257) + `"}}`, "must not exceed 256 characters"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := CompilePlugin(routingTestPluginSource("invalid-labels", 0, `["model"]`, "usageSchema: {mode: "+tc.field+"},", ""), Options{})
require.ErrorContains(t, err, tc.message)
})
}
}
func TestRegistryValidatesUsageExamples(t *testing.T) { func TestRegistryValidatesUsageExamples(t *testing.T) {
tokenSchema := `usageSchema: {tokens: {type: "number", unit: "token"}, mode: {enum: ["std", "pro"]}},` tokenSchema := `usageSchema: {tokens: {type: "number", unit: "token"}, mode: {enum: ["std", "pro"]}},`
validExample := `{label: "std · 1 token", facts: {tokens: 1, mode: "std"}}` validExample := `{label: "std · 1 token", facts: {tokens: 1, mode: "std"}}`
...@@ -843,3 +885,176 @@ func TestLocalizedTextContract(t *testing.T) { ...@@ -843,3 +885,176 @@ func TestLocalizedTextContract(t *testing.T) {
assert.Equal(t, "Generated media duration.", plugin.Meta.UsageSchema["seconds"].Description["en"]) assert.Equal(t, "Generated media duration.", plugin.Meta.UsageSchema["seconds"].Description["en"])
}) })
} }
func TestRegistryNormalizesBaseURL(t *testing.T) {
absent, err := CompilePlugin(routingTestPluginSource("base-url-absent", 0, `["model"]`, "", ""), Options{})
require.NoError(t, err)
assert.Empty(t, absent.Meta.BaseURL)
_, err = CompilePlugin(routingTestPluginSource("base-url-type", 0, `["model"]`, "baseUrl: 1,", ""), Options{})
require.ErrorContains(t, err, "baseUrl must be a string")
tests := []struct {
name string
input string
want string
wantError string
}{
{name: "https accepted", input: "https://api.example.com", want: "https://api.example.com"},
{name: "http accepted", input: "http://api.example.com", want: "http://api.example.com"},
{name: "loopback with port accepted", input: "http://127.0.0.1:8000", want: "http://127.0.0.1:8000"},
{name: "ipv6 literal with port and path accepted", input: "http://[::1]:8000/api/", want: "http://[::1]:8000/api"},
{name: "trailing slashes stripped", input: "https://api.example.com/v1//", want: "https://api.example.com/v1"},
{name: "scheme and host lowercased, path preserved", input: "HTTPS://API.Example.COM/V1", want: "https://api.example.com/V1"},
{name: "userinfo rejected", input: "https://user:pass@api.example.com", wantError: "credentials"},
{name: "query rejected", input: "https://api.example.com/?x=1", wantError: "query or fragment"},
{name: "fragment rejected", input: "https://api.example.com/#x", wantError: "query or fragment"},
{name: "non-http scheme rejected", input: "ftp://api.example.com", wantError: "http or https"},
{name: "relative value rejected", input: "/v1", wantError: "absolute"},
{name: "non-ascii host rejected", input: "https://例子.com", wantError: "ASCII"},
{name: "embedded whitespace rejected", input: "https://api.example.com/a b", wantError: "whitespace"},
{name: "192 characters rejected", input: "https://api.example.com/" + strings.Repeat("a", 168), wantError: "191"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
loaded, err := CompilePlugin(routingTestPluginSource("base-url", 0, `["model"]`, fmt.Sprintf("baseUrl: %q,", test.input), ""), Options{})
if test.wantError != "" {
require.ErrorContains(t, err, test.wantError)
return
}
require.NoError(t, err)
assert.Equal(t, test.want, loaded.Meta.BaseURL)
})
}
}
func TestRegistryNormalizesAllowedHosts(t *testing.T) {
tests := []struct {
name string
hosts string
want []string
wantError string
}{
{name: "hostname lowercased", hosts: `["Upload.Example.com"]`, want: []string{"upload.example.com"}},
{name: "host with port accepted", hosts: `["upload.example.com:8443"]`, want: []string{"upload.example.com:8443"}},
{name: "bracketed ipv6 with port accepted", hosts: `["[::1]:8080"]`, want: []string{"[::1]:8080"}},
{name: "bracketed ipv6 accepted", hosts: `["[::1]"]`, want: []string{"[::1]"}},
{name: "bare ipv6 normalized to brackets", hosts: `["::1"]`, want: []string{"[::1]"}},
{name: "scheme rejected", hosts: `["https://upload.example.com"]`, wantError: "without schemes"},
{name: "path rejected", hosts: `["upload.example.com/v1"]`, wantError: "without schemes"},
{name: "credentials rejected", hosts: `["user@upload.example.com"]`, wantError: "without schemes"},
{name: "port out of range rejected", hosts: `["upload.example.com:99999"]`, wantError: "between 1 and 65535"},
{name: "non-numeric port rejected", hosts: `["upload.example.com:abc"]`, wantError: "between 1 and 65535"},
{name: "malformed ipv6 rejected", hosts: `["fe80::1::2"]`, wantError: "bracketed"},
{name: "duplicate after normalization rejected", hosts: `["a.example.com", "A.example.com"]`, wantError: "unique"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
loaded, err := CompilePlugin(routingTestPluginSource("hosts", 0, `["model"]`, "allowedHosts: "+test.hosts+",", ""), Options{})
if test.wantError != "" {
require.ErrorContains(t, err, test.wantError)
return
}
require.NoError(t, err)
assert.Equal(t, test.want, loaded.Meta.AllowedHosts)
})
}
}
func TestRegistryRejectsImageReferencesInMetaIcon(t *testing.T) {
for _, icon := range []string{"data:image/png;base64,iVBORw0KGgo=", "https://example.com/icon.png"} {
_, err := CompilePlugin(routingTestPluginSource("icon-ref", 0, `["model"]`, fmt.Sprintf("icon: %q,", icon), ""), Options{})
require.ErrorContains(t, err, "icon.svg or icon.png file")
}
}
func TestDecodeIconDataURI(t *testing.T) {
svg := func(body string) string {
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(body))
}
pngBytes := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 16)...)
tests := []struct {
name string
icon string
wantType string
wantError string
}{
{name: "small svg accepted", icon: svg(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16 16"><defs><circle id="a" cx="8" cy="8" r="6" fill="#0af"/></defs><use xlink:href="#a"/><style>@media (prefers-color-scheme: dark) { circle { fill: #fff } }</style></svg>`), wantType: "image/svg+xml"},
{name: "small png accepted", icon: "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes), wantType: "image/png"},
{name: "svg script element rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`), wantError: "script"},
{name: "svg foreignObject rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div/></foreignObject></svg>`), wantError: "foreignObject"},
{name: "svg event handler attribute rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"/>`), wantError: "event handler"},
{name: "svg external image href rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><image href="https://evil.example/a.png"/></svg>`), wantError: "external"},
{name: "svg javascript href with entity split rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><a href="java&#10;script:alert(1)"><text>x</text></a></svg>`), wantError: "external"},
{name: "svg style import rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><style>@import url(evil.css)</style></svg>`), wantError: "external"},
{name: "svg doctype rejected", icon: svg(`<!DOCTYPE svg [<!ENTITY x "y">]><svg xmlns="http://www.w3.org/2000/svg"/>`), wantError: "DOCTYPE"},
{name: "non-svg root rejected", icon: svg(`<html><svg/></html>`), wantError: "root element"},
{name: "malformed svg rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg"><g></svg>`), wantError: "well-formed"},
{name: "non-image media type rejected", icon: "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte("<b>x</b>")), wantError: "image/png"},
{name: "charset parameter rejected", icon: "data:image/svg+xml;charset=utf-8;base64," + base64.StdEncoding.EncodeToString([]byte("<svg/>")), wantError: "image/png"},
{name: "missing data prefix rejected", icon: "image/png;base64,iVBORw0KGgo=", wantError: "image/png"},
{name: "malformed base64 rejected", icon: "data:image/png;base64,@@@", wantError: "base64"},
{name: "png signature enforced", icon: "data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("not a png")), wantError: "not a PNG"},
{name: "oversize payload rejected", icon: svg(`<svg xmlns="http://www.w3.org/2000/svg">` + strings.Repeat("<g/>", 140000) + `</svg>`), wantError: "must not exceed"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mediaType, data, err := DecodeIconDataURI(test.icon)
if test.wantError != "" {
require.ErrorContains(t, err, test.wantError)
return
}
require.NoError(t, err)
assert.Equal(t, test.wantType, mediaType)
assert.NotEmpty(t, data)
})
}
}
func TestPluginDisplayMetadata(t *testing.T) {
for _, tc := range []struct {
name, fields string
priority int
website string
invalid bool
}{
{name: "omitted"},
{name: "zero and empty", fields: `sortPriority: 0, website: "",`},
{name: "positive", fields: `sortPriority: 20, website: " https://example.com/docs?q=1#intro ",`, priority: 20, website: "https://example.com/docs?q=1#intro"},
{name: "minimum", fields: `sortPriority: -2147483648,`, priority: -2147483648},
{name: "maximum", fields: `sortPriority: 2147483647,`, priority: 2147483647},
{name: "underflow", fields: `sortPriority: -2147483649,`, invalid: true},
{name: "overflow", fields: `sortPriority: 2147483648,`, invalid: true},
{name: "fraction", fields: `sortPriority: 1.5,`, invalid: true},
{name: "numeric string", fields: `sortPriority: "1",`, invalid: true},
{name: "null priority", fields: `sortPriority: null,`, invalid: true},
{name: "nonfinite", fields: `sortPriority: Infinity,`, invalid: true},
{name: "website type", fields: `website: 1,`, invalid: true},
{name: "http", fields: `website: "http://example.com",`, invalid: true},
{name: "relative", fields: `website: "/docs",`, invalid: true},
{name: "missing host", fields: `website: "https:///docs",`, invalid: true},
{name: "credentials", fields: `website: "https://user:pass@example.com",`, invalid: true},
{name: "empty credentials", fields: `website: "https://@example.com",`, invalid: true},
{name: "javascript", fields: `website: "javascript:alert(1)",`, invalid: true},
{name: "invalid host", fields: `website: "https://-example.com",`, invalid: true},
{name: "invalid port", fields: `website: "https://example.com:99999",`, invalid: true},
{name: "whitespace", fields: `website: "https://example.com/a b",`, invalid: true},
} {
t.Run(tc.name, func(t *testing.T) {
loaded, err := CompilePlugin(routingTestPluginSource("display", 0, `["model"]`, tc.fields, ""), Options{})
if tc.invalid {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.priority, loaded.Meta.SortPriority)
assert.Equal(t, tc.website, loaded.Meta.Website)
encoded, err := common.Marshal(loaded.Meta)
require.NoError(t, err)
var decoded Meta
require.NoError(t, common.Unmarshal(encoded, &decoded))
assert.Equal(t, tc.priority, decoded.SortPriority)
assert.Equal(t, tc.website, decoded.Website)
})
}
}
...@@ -23,7 +23,9 @@ func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error ...@@ -23,7 +23,9 @@ func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error
return nil return nil
} }
for _, allowed := range allowedHosts { for _, allowed := range allowedHosts {
allowedURL, parseErr := url.Parse("https://" + strings.TrimSpace(allowed)) // Parse with the request scheme so "host:443" matches an https request
// the same way an explicit default port on the request URL does.
allowedURL, parseErr := url.Parse(request.Scheme + "://" + strings.TrimSpace(allowed))
if parseErr == nil && requestHost == canonicalHost(allowedURL) { if parseErr == nil && requestHost == canonicalHost(allowedURL) {
return nil return nil
} }
......
...@@ -2,13 +2,14 @@ package plugins ...@@ -2,13 +2,14 @@ package plugins
import ( import (
"embed" "embed"
"encoding/base64"
"fmt" "fmt"
"io/fs" "io/fs"
"github.com/QuantumNous/new-api/pkg/jsplugin" "github.com/QuantumNous/new-api/pkg/jsplugin"
) )
//go:embed tasks/*/plugin.js //go:embed tasks
var taskPlugins embed.FS var taskPlugins embed.FS
func init() { func init() {
...@@ -28,6 +29,11 @@ func init() { ...@@ -28,6 +29,11 @@ func init() {
if _, registerErr := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{Key: key}); registerErr != nil { if _, registerErr := jsplugin.DefaultRegistry.RegisterFactory(source, jsplugin.Options{Key: key}); registerErr != nil {
panic(fmt.Sprintf("register embedded task plugin %s: %v", key, registerErr)) panic(fmt.Sprintf("register embedded task plugin %s: %v", key, registerErr))
} }
if mediaType, data, ok := Icon(key); ok {
if iconErr := jsplugin.ValidateIconImage(mediaType, data); iconErr != nil {
panic(fmt.Sprintf("embedded task plugin %s icon: %v", key, iconErr))
}
}
} }
} }
...@@ -39,3 +45,26 @@ func Source(key string) (string, error) { ...@@ -39,3 +45,26 @@ func Source(key string) (string, error) {
} }
return string(source), nil return string(source), nil
} }
// Icon returns the embedded sidecar logo for a factory plugin, if the plugin
// directory ships an icon.svg or icon.png next to plugin.js.
func Icon(key string) (mediaType string, data []byte, ok bool) {
if data, err := taskPlugins.ReadFile("tasks/" + key + "/icon.svg"); err == nil {
return "image/svg+xml", data, true
}
if data, err := taskPlugins.ReadFile("tasks/" + key + "/icon.png"); err == nil {
return "image/png", data, true
}
return "", nil, false
}
// IconDataURI returns the embedded factory logo in the same data URI form the
// task_plugins.icon column stores, so factory and override logos share one
// read path.
func IconDataURI(key string) string {
mediaType, data, ok := Icon(key)
if !ok {
return ""
}
return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(data)
}
...@@ -7,7 +7,7 @@ export const meta = { ...@@ -7,7 +7,7 @@ export const meta = {
en: "Alibaba Cloud Bailian Wanxiang video generation (text-to-video and image-to-video)", en: "Alibaba Cloud Bailian Wanxiang video generation (text-to-video and image-to-video)",
zh: "阿里云百炼万相视频生成(文生视频、图生视频)", zh: "阿里云百炼万相视频生成(文生视频、图生视频)",
}, },
version: "1.1.0", version: "1.1.1",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [17], channelTypes: [17],
models: [ models: [
...@@ -24,14 +24,17 @@ export const meta = { ...@@ -24,14 +24,17 @@ export const meta = {
], ],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" }, description: { en: "Video generation unit price", zh: "视频生成单价" },
}, },
// Requested output video resolution.
resolution: { resolution: {
enum: ["480P", "720P", "1080P"], enum: ["480P", "720P", "1080P"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" }, enumLabels: { "480P": { en: "480P", zh: "480P" }, "720P": { en: "720P", zh: "720P" }, "1080P": { en: "1080P", zh: "1080P" } },
description: { en: "Output video resolution", zh: "输出视频分辨率" },
}, },
}, },
routes: [ routes: [
......
...@@ -7,7 +7,7 @@ export const meta = { ...@@ -7,7 +7,7 @@ export const meta = {
en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)", en: "Volcengine Doubao Seedance video generation (text-to-video, image-to-video, and video-to-video)",
zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)", zh: "火山引擎豆包 Seedance 视频生成(文生视频、图生视频、视频生视频)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format channelTypes: [54, 45], // VolcEngine-type channels serve Ark video models with the same wire format
models: [ models: [
...@@ -22,27 +22,28 @@ export const meta = { ...@@ -22,27 +22,28 @@ export const meta = {
], ],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Upstream billing tokens (estimated at submit, actual on completion).
tokens: { tokens: {
type: "number", type: "number",
unit: "token", unit: "token",
description: { description: { en: "Billing token unit price", zh: "计费 Token 单价" },
en: "Upstream billing tokens (estimated at submit, actual on completion).",
zh: "上游计费 token(提交时预估,完成后按实际值)。",
},
}, },
// Output video resolution; Seedance token unit price varies by resolution tier.
resolution: { resolution: {
enum: ["480p", "720p", "1080p", "4k"], enum: ["480p", "720p", "1080p", "4k"],
description: { enumLabels: {
en: "Output video resolution; Seedance token unit price varies by resolution tier.", "480p": { en: "480p", zh: "480p" },
zh: "输出视频分辨率;Seedance token 单价随分辨率档位变化。", "720p": { en: "720p", zh: "720p" },
"1080p": { en: "1080p", zh: "1080p" },
"4k": { en: "4k", zh: "4k" },
}, },
description: { en: "Output video resolution", zh: "输出视频分辨率" },
}, },
// Whether the request includes reference video input; Seedance prices video-to-video tokens at a lower unit rate.
video_input: { video_input: {
enum: ["none", "video"], enum: ["none", "video"],
description: { enumLabels: { none: { en: "No reference video", zh: "无参考视频" }, video: { en: "With reference video", zh: "有参考视频" } },
en: "Whether the request includes reference video input; Seedance prices video-to-video tokens at a lower unit rate.", description: { en: "Reference video input", zh: "参考视频输入" },
zh: "请求是否包含参考视频输入;Seedance 对视频生视频 token 按更低单价计费。",
},
}, },
}, },
// Official Ark formula tokens = (input + output seconds) × W × H × 24 / 1024, // Official Ark formula tokens = (input + output seconds) × W × H × 24 / 1024,
......
...@@ -7,26 +7,23 @@ export const meta = { ...@@ -7,26 +7,23 @@ export const meta = {
en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)", en: "Google Veo video generation on the Gemini API (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本", zh: "Google Veo 视频生成(文生视频、图生视频),Gemini API 版本",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [24], channelTypes: [24],
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"], models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds. Allowed values: 4, 6, 8.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "Requested video duration in seconds. Allowed values: 4, 6, 8.",
zh: "请求的视频时长,单位为秒。允许值为 4、6、8。",
},
}, },
// Requested video output resolution. Veo prices differ per resolution tier.
resolution: { resolution: {
enum: ["720p", "1080p", "4k"], enum: ["720p", "1080p", "4k"],
description: { enumLabels: { "720p": { en: "720p", zh: "720p" }, "1080p": { en: "1080p", zh: "1080p" }, "4k": { en: "4k", zh: "4k" } },
en: "Requested video output resolution. Veo prices differ per resolution tier.", description: { en: "Output video resolution", zh: "输出视频分辨率" },
zh: "请求的输出视频分辨率。Veo 各分辨率档位计费不同。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,7 +7,7 @@ export const meta = { ...@@ -7,7 +7,7 @@ export const meta = {
en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)", en: "MiniMax Hailuo video generation (text-to-video, image-to-video, and MiniMax-H3 multimodal reference)",
zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)", zh: "MiniMax 海螺视频生成(文生视频、图生视频、MiniMax-H3 多模态参考生视频)",
}, },
version: "1.1.2", version: "1.1.3",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [35], channelTypes: [35],
models: [ models: [
...@@ -24,33 +24,35 @@ export const meta = { ...@@ -24,33 +24,35 @@ export const meta = {
], ],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds. MiniMax-H3 allows 4 to 15; Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "Requested video duration in seconds. MiniMax-H3 allows 4 to 15; Hailuo 2.3/02/2.3-Fast allow 6 or 10; 01-series allow 6.",
zh: "请求的视频时长,单位为秒。MiniMax-H3 允许 4 到 15;Hailuo 2.3/02/2.3-Fast 允许 6 或 10;01 系列允许 6。",
},
}, },
// Requested output video resolution.
resolution: { resolution: {
enum: ["512P", "768P", "720P", "1080P", "2K"], enum: ["512P", "768P", "720P", "1080P", "2K"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" }, enumLabels: {
"512P": { en: "512P", zh: "512P" },
"768P": { en: "768P", zh: "768P" },
"720P": { en: "720P", zh: "720P" },
"1080P": { en: "1080P", zh: "1080P" },
"2K": { en: "2K", zh: "2K" },
}, },
description: { en: "Output video resolution", zh: "输出视频分辨率" },
},
// H3 input image count (estimated at submit, actual on completion).
input_images: { input_images: {
type: "number", type: "number",
unit: "count", unit: "count",
description: { description: { en: "Input image unit price", zh: "输入图片单价" },
en: "H3 input image count (estimated at submit, actual on completion).",
zh: "H3 输入图片数量(提交时预估,完成后按实际值)。",
},
}, },
// H3 input video duration in seconds (reserved at the request maximum, actual on completion).
input_video_seconds: { input_video_seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Input video unit price", zh: "输入视频单价" },
en: "H3 input video duration in seconds (reserved at the request maximum, actual on completion).",
zh: "H3 输入视频时长,单位为秒(提交时按请求上限预留,完成后按实际值)。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,23 +7,28 @@ export const meta = { ...@@ -7,23 +7,28 @@ export const meta = {
en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)", en: "Volcengine Jimeng video generation (text-to-video, image-to-video, and first-and-last-frame)",
zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)", zh: "火山引擎即梦视频生成(文生视频、图生视频、首尾帧)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [51], channelTypes: [51],
models: ["jimeng_vgfm_t2v_l20"], models: ["jimeng_vgfm_t2v_l20"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds. S2.0 Pro is fixed at 5; 3.0 req_keys allow 5 or 10.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "Requested video duration in seconds. S2.0 Pro is fixed at 5; 3.0 req_keys allow 5 or 10.",
zh: "请求的视频时长,单位为秒。S2.0 Pro 固定为 5;3.0 req_keys 允许 5 或 10。",
},
}, },
// Product tier derived from the final outbound req_key.
product: { product: {
enum: ["s2_pro", "v30_720p", "v30_1080p", "v30_pro"], enum: ["s2_pro", "v30_720p", "v30_1080p", "v30_pro"],
description: { en: "Product tier derived from the final outbound req_key.", zh: "由最终出站 req_key 推导出的产品档位。" }, enumLabels: {
s2_pro: { en: "S2.0 Pro", zh: "S2.0 Pro" },
v30_720p: { en: "3.0 720p", zh: "3.0 720p" },
v30_1080p: { en: "3.0 1080p", zh: "3.0 1080p" },
v30_pro: { en: "3.0 Pro", zh: "3.0 Pro" },
},
description: { en: "Product tier", zh: "产品档位" },
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,19 +7,17 @@ export const meta = { ...@@ -7,19 +7,17 @@ export const meta = {
en: "Kuaishou Kling video generation (text-to-video and image-to-video)", en: "Kuaishou Kling video generation (text-to-video and image-to-video)",
zh: "快手可灵视频生成(文生视频、图生视频)", zh: "快手可灵视频生成(文生视频、图生视频)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [50], channelTypes: [50],
models: ["kling-v1", "kling-v1-6", "kling-v2-master"], models: ["kling-v1", "kling-v1-6", "kling-v2-master"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Kling final unit deduction (estimated at submit, actual on completion).
units: { units: {
type: "number", type: "number",
unit: "credit", unit: "credit",
description: { description: { en: "Kling credit unit price", zh: "可灵资源包单位单价" },
en: "Kling final unit deduction (estimated at submit, actual on completion).",
zh: "可灵最终单位消耗(提交时预估,完成后按实际值)。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,20 +7,28 @@ export const meta = { ...@@ -7,20 +7,28 @@ export const meta = {
en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)", en: "OpenAI Sora video generation (text-to-video, image-to-video, and remix)",
zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)", zh: "OpenAI Sora 视频生成(文生视频、图生视频、remix)",
}, },
version: "1.0.1", version: "1.0.2",
channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format channelTypes: [55, 1], // OpenAI-type channels natively serve sora with the same wire format
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
models: ["sora-2", "sora-2-pro"], models: ["sora-2", "sora-2-pro"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// Requested video duration in seconds.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" }, description: { en: "Video generation unit price", zh: "视频生成单价" },
}, },
// Requested output video dimensions.
size: { size: {
enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"], enum: ["720x1280", "1280x720", "1792x1024", "1024x1792"],
description: { en: "Requested output video dimensions.", zh: "请求的输出视频尺寸。" }, enumLabels: {
"720x1280": { en: "720x1280", zh: "720x1280" },
"1280x720": { en: "1280x720", zh: "1280x720" },
"1792x1024": { en: "1792x1024", zh: "1792x1024" },
"1024x1792": { en: "1024x1792", zh: "1024x1792" },
},
description: { en: "Output video dimensions", zh: "输出视频尺寸" },
}, },
}, },
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"], protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
......
...@@ -9,18 +9,24 @@ export const meta = { ...@@ -9,18 +9,24 @@ export const meta = {
en: "SunoAPI project music and lyrics generation", en: "SunoAPI project music and lyrics generation",
zh: "SunoAPI 项目 音乐与歌词生成", zh: "SunoAPI 项目 音乐与歌词生成",
}, },
version: "1.0.2", version: "1.0.3",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [36], channelTypes: [36],
models: ["suno_music", "suno_lyrics"], models: ["suno_music", "suno_lyrics"],
fetchMode: "batch", fetchMode: "batch",
usageSchema: { usageSchema: {
// Number of generated music or lyrics clips.
clips: { clips: {
type: "number", type: "number",
unit: "count", unit: "count",
description: { en: "Number of generated music or lyrics clips.", zh: "生成的音乐或歌词片段数量。" }, description: { en: "Song or lyrics generation unit price", zh: "生成歌曲或歌词单价" },
},
// Suno generation action.
action: {
enum: ["music", "lyrics"],
enumLabels: { music: { en: "Generate songs", zh: "生成歌曲" }, lyrics: { en: "Generate lyrics", zh: "生成歌词" } },
description: { en: "Generate songs or lyrics", zh: "生成歌曲或歌词" },
}, },
action: { enum: ["music", "lyrics"], description: { en: "Suno generation action.", zh: "Suno 生成动作。" } },
}, },
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }], protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }],
routes: [ routes: [
......
...@@ -7,31 +7,29 @@ export const meta = { ...@@ -7,31 +7,29 @@ export const meta = {
en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)", en: "Google Veo video generation on Vertex AI (text-to-video and image-to-video)",
zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本", zh: "Google Veo 视频生成(文生视频、图生视频),Vertex AI 版本",
}, },
version: "1.0.1", version: "1.0.2",
channelTypes: [41], channelTypes: [41],
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"], models: ["veo-3.0-generate-001", "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", "veo-3.1-fast-generate-preview"],
fetchMode: "per_task", fetchMode: "per_task",
auth: { type: "oauth2_jwt" }, auth: { type: "oauth2_jwt" },
usageSchema: { usageSchema: {
// Requested video duration in seconds. Allowed values: 4, 6, 8.
seconds: { seconds: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { description: { en: "Video generation unit price", zh: "视频生成单价" },
en: "Requested video duration in seconds. Allowed values: 4, 6, 8.",
zh: "请求的视频时长,单位为秒。允许值为 4、6、8。",
},
}, },
// Requested video output resolution.
resolution: { resolution: {
enum: ["720p", "1080p", "4k"], enum: ["720p", "1080p", "4k"],
description: { en: "Requested video output resolution.", zh: "请求的输出视频分辨率。" }, enumLabels: { "720p": { en: "720p", zh: "720p" }, "1080p": { en: "1080p", zh: "1080p" }, "4k": { en: "4k", zh: "4k" } },
description: { en: "Output video resolution", zh: "输出视频分辨率" },
}, },
// Whether audio is generated. Default true. Audio and muted tiers have different prices.
generate_audio: { generate_audio: {
type: "boolean", type: "boolean",
description: { description: { en: "Whether audio is generated", zh: "是否生成音频" },
en: "Whether audio is generated. Default true. Audio and muted tiers have different prices.",
zh: "是否生成音频。默认为 true。有声与静音档位计费不同。",
},
}, },
}, },
usageExamples: [ usageExamples: [
......
...@@ -7,25 +7,34 @@ export const meta = { ...@@ -7,25 +7,34 @@ export const meta = {
en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)", en: "Shengshu Vidu video generation (text-to-video, image-to-video, first-and-last-frame, and reference-to-video)",
zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)", zh: "生数 Vidu 视频生成(文生视频、图生视频、首尾帧、参考生视频)",
}, },
version: "1.0.1", version: "1.0.2",
author: { name: "QuantumNous" }, author: { name: "QuantumNous" },
channelTypes: [52], channelTypes: [52],
models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"], models: ["viduq2", "viduq1", "vidu2.0", "vidu1.5"],
fetchMode: "per_task", fetchMode: "per_task",
usageSchema: { usageSchema: {
// estimated/actual Vidu credits
credits: { credits: {
type: "number", type: "number",
unit: "credit", unit: "credit",
description: { en: "estimated/actual Vidu credits", zh: "预估/实际的 Vidu 积分" }, description: { en: "Vidu credit unit price", zh: "Vidu 积分单价" },
}, },
// Requested video duration in seconds.
duration: { duration: {
type: "number", type: "number",
unit: "second", unit: "second",
description: { en: "Requested video duration in seconds.", zh: "请求的视频时长,单位为秒。" }, description: { en: "Video generation unit price", zh: "视频生成单价" },
}, },
// Requested output video resolution.
resolution: { resolution: {
enum: ["360p", "540p", "720p", "1080p"], enum: ["360p", "540p", "720p", "1080p"],
description: { en: "Requested output video resolution.", zh: "请求的输出视频分辨率。" }, enumLabels: {
"360p": { en: "360p", zh: "360p" },
"540p": { en: "540p", zh: "540p" },
"720p": { en: "720p", zh: "720p" },
"1080p": { en: "1080p", zh: "1080p" },
},
description: { en: "Output video resolution", zh: "输出视频分辨率" },
}, },
}, },
// credits is 0 in examples because this plugin does not estimate vendor credits; // credits is 0 in examples because this plugin does not estimate vendor credits;
......
...@@ -255,6 +255,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -255,6 +255,7 @@ func SetApiRouter(router *gin.Engine) {
taskPluginRoute.GET("/marketplace/sources", controller.GetTaskPluginMarketplaceSources) taskPluginRoute.GET("/marketplace/sources", controller.GetTaskPluginMarketplaceSources)
taskPluginRoute.PUT("/marketplace/sources", controller.UpdateTaskPluginMarketplaceSources) taskPluginRoute.PUT("/marketplace/sources", controller.UpdateTaskPluginMarketplaceSources)
taskPluginRoute.GET("/:key", controller.GetTaskPlugin) taskPluginRoute.GET("/:key", controller.GetTaskPlugin)
taskPluginRoute.GET("/:key/icon", controller.GetTaskPluginIcon)
taskPluginRoute.GET("/:key/versions", controller.GetTaskPluginVersions) taskPluginRoute.GET("/:key/versions", controller.GetTaskPluginVersions)
taskPluginRoute.POST("/:key/activate", controller.ActivateTaskPlugin) taskPluginRoute.POST("/:key/activate", controller.ActivateTaskPlugin)
taskPluginRoute.POST("/:key/status", controller.SetTaskPluginStatus) taskPluginRoute.POST("/:key/status", controller.SetTaskPluginStatus)
......
...@@ -498,6 +498,9 @@ func TestPluginPathOwnershipIsExclusiveAcrossMethods(t *testing.T) { ...@@ -498,6 +498,9 @@ func TestPluginPathOwnershipIsExclusiveAcrossMethods(t *testing.T) {
beta := compileRouterPlugin(t, "beta-owner", "1.0.0", `[ beta := compileRouterPlugin(t, "beta-owner", "1.0.0", `[
{method: "POST", path: "/shared/jobs/:task_id", type: "submit"} {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) { handlers := testPluginRouteHandlers(func(c *gin.Context, _ *jsplugin.RoutingGeneration, binding jsplugin.RouteBinding) {
c.String(http.StatusOK, binding.Plugin.Meta.Key) c.String(http.StatusOK, binding.Plugin.Meta.Key)
}) })
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment