mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
fix(plugins): store task plugin source as longtext on MySQL and raise the upload cap to 8 MiB
A bare TEXT column holds 64 KiB on MySQL, and the shipped alibaba plugin is already 73 KB, so uploading an override of it failed with "Data too long for column 'source'" or was silently truncated. The 1 MiB upload cap also blocks the larger plugins planned next. Add model.LongText, a string type that picks longtext on MySQL and text on PostgreSQL and SQLite through GormDBDataType, and use it for source and icon. Neither type carries a length, so AutoMigrate leaves an up-to-date column alone and widens the shipped MySQL text column once without data loss. Raise the source cap to 8 MiB on the backend and in the frontend picker, importers and locale strings. Verified fresh and rc.39-upgrade startups twice each on SQLite, MySQL 5.7.44 and 8.4.11, PostgreSQL 9.6.24 and 17.11; the second start issues no DDL and a 73 KB source round-trips on every engine.
This commit is contained in:
+12
-12
@@ -26,7 +26,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const maxTaskPluginSourceBytes = 1024 * 1024
|
||||
const maxTaskPluginSourceBytes = 8 << 20
|
||||
|
||||
func taskPluginCompileError(c *gin.Context, err error) {
|
||||
var unknownField *jsplugin.UnknownMetaFieldError
|
||||
@@ -55,7 +55,7 @@ func UploadTaskPlugin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if len(request.Source) > maxTaskPluginSourceBytes {
|
||||
common.ApiErrorMsg(c, "plugin source exceeds 1 MiB")
|
||||
common.ApiErrorMsg(c, "plugin source exceeds 8 MiB")
|
||||
return
|
||||
}
|
||||
if expected := strings.TrimSpace(request.SourceSha256); expected != "" {
|
||||
@@ -94,8 +94,8 @@ func UploadTaskPlugin(c *gin.Context) {
|
||||
}
|
||||
plugin := model.TaskPlugin{
|
||||
Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
|
||||
Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
|
||||
Icon: icon, Enabled: enabled, Remark: request.Remark,
|
||||
Source: model.LongText(request.Source), SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
|
||||
Icon: model.LongText(icon), Enabled: enabled, Remark: request.Remark,
|
||||
}
|
||||
if err = model.SaveTaskPlugin(&plugin); err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -105,7 +105,7 @@ func UploadTaskPlugin(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
|
||||
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: string(plugin.Source), Layer: "override", HasIcon: plugin.HasIcon()})
|
||||
}
|
||||
|
||||
func GetTaskPluginVersions(c *gin.Context) {
|
||||
@@ -195,7 +195,7 @@ func ListTaskPlugins(c *gin.Context) {
|
||||
item.FactoryMeta = &factoryCopy
|
||||
}
|
||||
item.Meta = jsplugin.Meta{Key: row.Key, Version: row.Version, APIVersion: row.APIVersion}
|
||||
if compiled, compileErr := jsplugin.NewRegistry().Register(row.Source, jsplugin.Options{Key: row.Key, Version: row.Version}); compileErr == nil {
|
||||
if compiled, compileErr := jsplugin.NewRegistry().Register(string(row.Source), jsplugin.Options{Key: row.Key, Version: row.Version}); compileErr == nil {
|
||||
item.Meta = compiled.Meta
|
||||
}
|
||||
item.Enabled = row.Enabled
|
||||
@@ -316,7 +316,7 @@ func GetTaskPluginIcon(c *gin.Context) {
|
||||
icon := ""
|
||||
plugin, err := model.GetTaskPluginVersion(key, c.Query("version"))
|
||||
if err == nil {
|
||||
icon = plugin.Icon
|
||||
icon = string(plugin.Icon)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -344,12 +344,12 @@ func GetTaskPlugin(c *gin.Context) {
|
||||
version := c.Query("version")
|
||||
plugin, err := model.GetTaskPluginVersion(key, version)
|
||||
if err == nil {
|
||||
loaded, compileErr := jsplugin.NewRegistry().Register(plugin.Source, jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
|
||||
loaded, compileErr := jsplugin.NewRegistry().Register(string(plugin.Source), jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
|
||||
if compileErr != nil {
|
||||
taskPluginCompileError(c, compileErr)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
|
||||
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: string(plugin.Source), Layer: "override", HasIcon: plugin.HasIcon()})
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
|
||||
@@ -385,7 +385,7 @@ func DryRunTaskPlugin(c *gin.Context) {
|
||||
detailSource := ""
|
||||
plugin, err := model.GetTaskPluginVersion(c.Param("key"), "")
|
||||
if err == nil {
|
||||
detailSource = plugin.Source
|
||||
detailSource = string(plugin.Source)
|
||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
detailSource, err = plugins.Source(c.Param("key"))
|
||||
}
|
||||
@@ -483,7 +483,7 @@ func ActivateTaskPlugin(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "plugin version not found")
|
||||
return
|
||||
}
|
||||
if _, err = jsplugin.NewRegistry().Register(target.Source, jsplugin.Options{Key: target.Key, Version: target.Version}); err != nil {
|
||||
if _, err = jsplugin.NewRegistry().Register(string(target.Source), jsplugin.Options{Key: target.Key, Version: target.Version}); err != nil {
|
||||
taskPluginCompileError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -770,7 +770,7 @@ func syncTaskPluginsOnceContext(ctx context.Context) error {
|
||||
plugin.Key,
|
||||
plugin.Version,
|
||||
)
|
||||
compiled, compileErr := jsplugin.CompilePlugin(plugin.Source, jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
|
||||
compiled, compileErr := jsplugin.CompilePlugin(string(plugin.Source), jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
|
||||
if compileErr != nil {
|
||||
retainedIncumbent := false
|
||||
if current := currentOverrides[plugin.Key]; current != nil {
|
||||
|
||||
@@ -272,7 +272,7 @@ func TestDisableFactoryOverrideRowSuppressesBothLayers(t *testing.T) {
|
||||
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
|
||||
plugin := model.TaskPlugin{
|
||||
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
|
||||
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
|
||||
Source: model.LongText(overrideSource), SourceHash: "test-hash", Enabled: true,
|
||||
}
|
||||
require.NoError(t, model.SaveTaskPlugin(&plugin))
|
||||
require.NoError(t, syncTaskPluginsOnce())
|
||||
@@ -326,6 +326,64 @@ export function parseTaskResult() { return {}; }
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
// The shipped alibaba plugin is already larger than a MySQL TEXT column
|
||||
// (64 KiB), so storing it through the model path the upload endpoint uses and
|
||||
// reading it back unchanged proves the source and icon columns are not
|
||||
// truncated. Set TEST_TASK_DB_DIALECT plus TEST_MYSQL_DSN or TEST_POSTGRES_DSN
|
||||
// to run the same contract against a real MySQL or PostgreSQL server.
|
||||
func TestTaskPluginSourceAboveMySQLTextLimitRoundTrips(t *testing.T) {
|
||||
database, _ := openTaskDialectDatabase(t, &model.TaskPlugin{})
|
||||
originalDB := model.DB
|
||||
model.DB = database
|
||||
t.Cleanup(func() { model.DB = originalDB })
|
||||
|
||||
source, err := plugins.Source("alibaba")
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(source), 65535, "fixture must exceed the MySQL TEXT limit")
|
||||
icon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(strings.Repeat(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`, 2048)))
|
||||
require.Greater(t, len(icon), 65535, "icon fixture must exceed the MySQL TEXT limit")
|
||||
|
||||
plugin := model.TaskPlugin{Key: "alibaba", APIVersion: 1, Version: "roundtrip", Source: model.LongText(source), SourceHash: "hash", Enabled: true}
|
||||
require.NoError(t, model.SaveTaskPlugin(&plugin))
|
||||
// Re-saving the same version attaches the icon through the update path.
|
||||
withIcon := model.TaskPlugin{Key: "alibaba", APIVersion: 1, Version: "roundtrip", Source: model.LongText(source), SourceHash: "hash", Icon: model.LongText(icon), Enabled: true}
|
||||
require.NoError(t, model.SaveTaskPlugin(&withIcon))
|
||||
|
||||
stored, err := model.GetTaskPluginVersion("alibaba", "roundtrip")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, source, string(stored.Source))
|
||||
assert.Equal(t, icon, string(stored.Icon))
|
||||
}
|
||||
|
||||
func TestUploadTaskPluginAcceptsSourcesUpToEightMiB(t *testing.T) {
|
||||
setupTaskPluginControllerTest(t)
|
||||
cleanupTaskPluginControllerRuntime(t, "large-source")
|
||||
upload := func(source string) *httptest.ResponseRecorder {
|
||||
body, err := common.Marshal(map[string]any{"source": source})
|
||||
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
|
||||
}
|
||||
|
||||
// A trailing comment pads a valid plugin past the former 1 MiB cap.
|
||||
accepted := taskPluginControllerTestSource("large-source", "1.0.0") + "// " + strings.Repeat("x", 2<<20) + "\n"
|
||||
recorder := upload(accepted)
|
||||
require.Contains(t, recorder.Body.String(), `"success":true`)
|
||||
stored, err := model.GetTaskPluginVersion("large-source", "1.0.0")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, accepted, string(stored.Source))
|
||||
|
||||
rejected := upload(accepted + strings.Repeat("x", 6<<20))
|
||||
assert.Contains(t, rejected.Body.String(), `"success":false`)
|
||||
assert.Contains(t, rejected.Body.String(), "plugin source exceeds 8 MiB")
|
||||
_, err = model.GetTaskPluginVersion("large-source", "2.0.0")
|
||||
assert.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
func TestListTaskPluginsIncludesFactoryWithoutDatabaseRows(t *testing.T) {
|
||||
setupTaskPluginControllerTest(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -443,7 +501,7 @@ func TestDeleteActiveOverrideFallsBackToFactoryAndDeletesRecord(t *testing.T) {
|
||||
t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("kling") })
|
||||
plugin := model.TaskPlugin{
|
||||
Key: "kling", APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
|
||||
Source: overrideSource, SourceHash: "test-hash", Enabled: true,
|
||||
Source: model.LongText(overrideSource), SourceHash: "test-hash", Enabled: true,
|
||||
}
|
||||
require.NoError(t, model.SaveTaskPlugin(&plugin))
|
||||
recorder := httptest.NewRecorder()
|
||||
@@ -469,10 +527,10 @@ func TestDeleteActiveTaskPluginPromotesEnabledVersionInRuntime(t *testing.T) {
|
||||
v1Source := taskPluginControllerTestSource(key, "1.0.0")
|
||||
v2Source := taskPluginControllerTestSource(key, "2.0.0")
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
|
||||
Key: key, APIVersion: 1, Version: "1.0.0", Source: model.LongText(v1Source), SourceHash: "hash-v1", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
|
||||
Key: key, APIVersion: 1, Version: "2.0.0", Source: model.LongText(v2Source), SourceHash: "hash-v2", Enabled: true,
|
||||
}))
|
||||
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
|
||||
require.NoError(t, err)
|
||||
@@ -538,10 +596,10 @@ func TestActivateTaskPluginRefreshesRuntimeSyncState(t *testing.T) {
|
||||
v1Source := taskPluginControllerTestSource(key, "1.0.0")
|
||||
v2Source := taskPluginControllerTestSource(key, "2.0.0")
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: key, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "hash-v1", Enabled: true,
|
||||
Key: key, APIVersion: 1, Version: "1.0.0", Source: model.LongText(v1Source), SourceHash: "hash-v1", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: key, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "hash-v2", Enabled: true,
|
||||
Key: key, APIVersion: 1, Version: "2.0.0", Source: model.LongText(v2Source), SourceHash: "hash-v2", Enabled: true,
|
||||
}))
|
||||
_, err := jsplugin.DefaultRegistry.Register(v1Source, jsplugin.Options{Key: key, Version: "1.0.0"})
|
||||
require.NoError(t, err)
|
||||
@@ -578,10 +636,10 @@ func TestSyncTaskPluginsPublishesOneGenerationForWholeBatch(t *testing.T) {
|
||||
firstSource := taskPluginControllerTestSource(firstKey, "1.0.0")
|
||||
secondSource := taskPluginControllerTestSource(secondKey, "1.0.0")
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: firstKey, APIVersion: 1, Version: "1.0.0", Source: firstSource, SourceHash: "first-hash", Enabled: true,
|
||||
Key: firstKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(firstSource), SourceHash: "first-hash", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: secondSource, SourceHash: "second-hash", Enabled: true,
|
||||
Key: secondKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(secondSource), SourceHash: "second-hash", Enabled: true,
|
||||
}))
|
||||
before := jsplugin.DefaultRegistry.Generation().Number
|
||||
|
||||
@@ -614,7 +672,7 @@ func TestTaskPluginRuntimeExposesDatabaseRevisionAheadOfLocalGeneration(t *testi
|
||||
v1Source := taskPluginControllerTestSource(key, "1.0.0")
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: key, APIVersion: 1, Version: "1.0.0",
|
||||
Source: v1Source, SourceHash: "runtime-v1", Enabled: true,
|
||||
Source: model.LongText(v1Source), SourceHash: "runtime-v1", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, syncTaskPluginsOnce())
|
||||
localGeneration := jsplugin.DefaultRegistry.Generation().Number
|
||||
@@ -625,7 +683,7 @@ func TestTaskPluginRuntimeExposesDatabaseRevisionAheadOfLocalGeneration(t *testi
|
||||
v2Source := taskPluginControllerTestSource(key, "2.0.0")
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: key, APIVersion: 1, Version: "2.0.0",
|
||||
Source: v2Source, SourceHash: "runtime-v2", Enabled: true,
|
||||
Source: model.LongText(v2Source), SourceHash: "runtime-v2", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, model.ActivateTaskPlugin(key, "2.0.0"))
|
||||
|
||||
@@ -705,7 +763,7 @@ func TestTaskPluginRuntimeSurvivesDatabaseSyncFailure(t *testing.T) {
|
||||
source := taskPluginControllerTestSource(key, "1.0.0")
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: key, APIVersion: 1, Version: "1.0.0",
|
||||
Source: source, SourceHash: "runtime-database-v1", Enabled: true,
|
||||
Source: model.LongText(source), SourceHash: "runtime-database-v1", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, syncTaskPluginsOnce())
|
||||
generation := jsplugin.DefaultRegistry.Generation().Number
|
||||
@@ -747,17 +805,17 @@ func TestSyncTaskPluginsCachesRejectedDesiredSourceWithoutLosingIncumbent(t *tes
|
||||
v2Source := taskPluginControllerChannelSource(pluginKey, "2.0.0", 9002)
|
||||
ownerSource := taskPluginControllerChannelSource(ownerKey, "1.0.0", 9002)
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: pluginKey, APIVersion: 1, Version: "1.0.0", Source: v1Source, SourceHash: "retained-v1", Enabled: true,
|
||||
Key: pluginKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(v1Source), SourceHash: "retained-v1", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: ownerSource, SourceHash: "owner-v1", Enabled: true,
|
||||
Key: ownerKey, APIVersion: 1, Version: "1.0.0", Source: model.LongText(ownerSource), SourceHash: "owner-v1", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, syncTaskPluginsOnce())
|
||||
|
||||
incumbent, ok := jsplugin.DefaultRegistry.Get(pluginKey)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: pluginKey, APIVersion: 1, Version: "2.0.0", Source: v2Source, SourceHash: "retained-v2", Enabled: true,
|
||||
Key: pluginKey, APIVersion: 1, Version: "2.0.0", Source: model.LongText(v2Source), SourceHash: "retained-v2", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, model.ActivateTaskPlugin(pluginKey, "2.0.0"))
|
||||
|
||||
@@ -779,7 +837,7 @@ func TestSyncTaskPluginsCachesRejectedDesiredSourceWithoutLosingIncumbent(t *tes
|
||||
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{
|
||||
Key: ownerKey, APIVersion: 1, Version: "2.0.0",
|
||||
Source: taskPluginControllerChannelSource(ownerKey, "2.0.0", 9003), SourceHash: "owner-v2", Enabled: true,
|
||||
Source: model.LongText(taskPluginControllerChannelSource(ownerKey, "2.0.0", 9003)), SourceHash: "owner-v2", Enabled: true,
|
||||
}))
|
||||
require.NoError(t, model.ActivateTaskPlugin(ownerKey, "2.0.0"))
|
||||
require.NoError(t, syncTaskPluginsOnce())
|
||||
@@ -1198,7 +1256,7 @@ func TestTaskPluginDisplayOrderAndMetadata(t *testing.T) {
|
||||
_, 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}))
|
||||
require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: model.LongText(source), Enabled: true}))
|
||||
}
|
||||
for _, endpoint := range []string{"list", "options"} {
|
||||
t.Run(endpoint, func(t *testing.T) {
|
||||
@@ -1268,7 +1326,7 @@ func TestUploadTaskPluginStoresSidecarIconAndServesIt(t *testing.T) {
|
||||
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)
|
||||
assert.Equal(t, svgIcon, string(stored.Icon))
|
||||
|
||||
item := listTaskPluginItem(t, key)
|
||||
assert.True(t, item.HasIcon)
|
||||
|
||||
+31
-15
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/schema"
|
||||
)
|
||||
|
||||
type TaskPluginChannelRef struct {
|
||||
@@ -57,25 +58,40 @@ func UnbindTaskPlugin(channelID int, key string) (bool, error) {
|
||||
return true, DB.Model(&Channel{}).Where("id = ?", channelID).Update("setting", channel.Setting).Error
|
||||
}
|
||||
|
||||
// LongText is a string column sized for plugin payloads on every supported
|
||||
// database: longtext on MySQL, text on PostgreSQL and SQLite. A bare
|
||||
// `type:text` tag stops at 64 KiB on MySQL. A `size:` tag above that becomes
|
||||
// varchar(N) on PostgreSQL, which rejects sizes past 10485760, and a NOT NULL
|
||||
// column declared that way is re-altered by AutoMigrate on every MySQL start
|
||||
// because the reported column length never equals N. Neither type chosen here
|
||||
// carries a length, so an up-to-date column is left alone, and MySQL widens a
|
||||
// shipped text column once, without data loss.
|
||||
type LongText string
|
||||
|
||||
func (LongText) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
|
||||
if db.Dialector.Name() == string(common.DatabaseTypeMySQL) {
|
||||
return "longtext"
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
type TaskPlugin struct {
|
||||
Id int64 `json:"id"`
|
||||
Key string `json:"key" gorm:"size:128;not null;uniqueIndex:uk_task_plugin_key_version,priority:1"`
|
||||
APIVersion int `json:"api_version" gorm:"not null"`
|
||||
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"`
|
||||
SourceHash string `json:"source_hash" gorm:"size:64;not null"`
|
||||
Id int64 `json:"id"`
|
||||
Key string `json:"key" gorm:"size:128;not null;uniqueIndex:uk_task_plugin_key_version,priority:1"`
|
||||
APIVersion int `json:"api_version" gorm:"not null"`
|
||||
Version string `json:"version" gorm:"size:64;not null;uniqueIndex:uk_task_plugin_key_version,priority:2"`
|
||||
Source LongText `json:"source" gorm:"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"`
|
||||
Active bool `json:"active" gorm:"not null;index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"not null"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
// loads it through GET /api/plugin/task/:key/icon. LongText keeps the
|
||||
// 512 KiB icon cap storable on MySQL, where a bare TEXT holds only 64 KiB.
|
||||
Icon LongText `json:"-"`
|
||||
Enabled bool `json:"enabled" gorm:"not null"`
|
||||
Active bool `json:"active" gorm:"not null;index"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"not null"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
}
|
||||
|
||||
// HasIcon reports whether this version ships a logo.
|
||||
|
||||
@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -170,7 +171,7 @@ describe('browser plugin source fetch', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects a declared content-length above the 1 MiB backend limit before reading the body', async () => {
|
||||
test('rejects a declared content-length above the 8 MiB backend limit before reading the body', async () => {
|
||||
let bodyRead = false
|
||||
await assert.rejects(
|
||||
fetchPluginSourceText('https://example.com/huge.js', async () => {
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('UploadDialog file selection', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('rejects a file over the 1 MiB limit without touching the source', async () => {
|
||||
test('rejects a file over the 8 MiB limit without touching the source', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('UploadDialog file selection', () => {
|
||||
await user.upload(fileInput(), oversized)
|
||||
|
||||
expect(
|
||||
await screen.findByText('Plugin source exceeds the 1 MiB limit.')
|
||||
await screen.findByText('Plugin source exceeds the 8 MiB limit.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('huge.js')).toBeNull()
|
||||
expect(footerButton('Upload')).toBeDisabled()
|
||||
|
||||
@@ -188,7 +188,7 @@ function MarketplaceInstallContent(
|
||||
if (fetchError instanceof PluginSourceFetchError) {
|
||||
fetchErrorMessage =
|
||||
fetchError.reason === 'too_large'
|
||||
? t('Plugin source exceeds the 1 MiB limit.')
|
||||
? t('Plugin source exceeds the 8 MiB limit.')
|
||||
: t(
|
||||
'Could not fetch the plugin source from this browser. The host may block cross-origin requests or be unreachable.'
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ export function PluginSourcePicker(props: PluginSourcePickerProps) {
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Single .js file, up to 1 MiB. Its source is shown below before upload.'
|
||||
'Single .js file, up to 8 MiB. Its source is shown below before upload.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function PluginUrlImportField(props: PluginUrlImportFieldProps) {
|
||||
return
|
||||
}
|
||||
if (error.reason === 'too_large') {
|
||||
props.onError(t('Plugin source exceeds the 1 MiB limit.'))
|
||||
props.onError(t('Plugin source exceeds the 8 MiB limit.'))
|
||||
return
|
||||
}
|
||||
if (error.reason === 'not_found') {
|
||||
|
||||
@@ -108,7 +108,7 @@ export function UploadDialog(props: UploadDialogProps) {
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (file.size > MAX_PLUGIN_SOURCE_BYTES) {
|
||||
setImportError(t('Plugin source exceeds the 1 MiB limit.'))
|
||||
setImportError(t('Plugin source exceeds the 8 MiB limit.'))
|
||||
return
|
||||
}
|
||||
setImportError('')
|
||||
|
||||
@@ -21,7 +21,7 @@ For commercial licensing, please contact [email protected]
|
||||
* controller/task_plugin.go). Enforced client-side too so an oversized fetch
|
||||
* fails with a readable message instead of a server rejection.
|
||||
*/
|
||||
export const MAX_PLUGIN_SOURCE_BYTES = 1024 * 1024
|
||||
export const MAX_PLUGIN_SOURCE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
export function pluginSourceByteLength(source: string): number {
|
||||
return new TextEncoder().encode(source).length
|
||||
|
||||
@@ -4371,7 +4371,7 @@
|
||||
"Plugin sandbox": "Plugin sandbox",
|
||||
"Plugin saved, activation required": "Plugin saved, activation required",
|
||||
"Plugin source": "Plugin source",
|
||||
"Plugin source exceeds the 1 MiB limit.": "Plugin source exceeds the 1 MiB limit.",
|
||||
"Plugin source exceeds the 8 MiB limit.": "Plugin source exceeds the 8 MiB limit.",
|
||||
"Plugin uploaded successfully": "Plugin uploaded successfully",
|
||||
"Plugin version activated": "Plugin version activated",
|
||||
"Plugin version deleted": "Plugin version deleted",
|
||||
@@ -5429,7 +5429,7 @@
|
||||
"Simulation context is missing.": "Simulation context is missing.",
|
||||
"Simulation time": "Simulation time",
|
||||
"Since previous sample": "Since previous sample",
|
||||
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "Single .js file, up to 1 MiB. Its source is shown below before upload.",
|
||||
"Single .js file, up to 8 MiB. Its source is shown below before upload.": "Single .js file, up to 8 MiB. Its source is shown below before upload.",
|
||||
"Single Key": "Single Key",
|
||||
"Site & Branding": "Site & Branding",
|
||||
"Site currency ({{currency}})": "Site currency ({{currency}})",
|
||||
|
||||
@@ -4371,7 +4371,7 @@
|
||||
"Plugin sandbox": "Bac à sable du plugin",
|
||||
"Plugin saved, activation required": "Plugin enregistré, activation requise",
|
||||
"Plugin source": "Source du plugin",
|
||||
"Plugin source exceeds the 1 MiB limit.": "Le code du plugin dépasse la limite de 1 Mio.",
|
||||
"Plugin source exceeds the 8 MiB limit.": "Le code du plugin dépasse la limite de 8 Mio.",
|
||||
"Plugin uploaded successfully": "Plugin importé avec succès",
|
||||
"Plugin version activated": "Version du plugin activée",
|
||||
"Plugin version deleted": "Version du plugin supprimée",
|
||||
@@ -5429,7 +5429,7 @@
|
||||
"Simulation context is missing.": "Le contexte nécessaire à la simulation est manquant.",
|
||||
"Simulation time": "Heure de simulation",
|
||||
"Since previous sample": "Depuis le relevé précédent",
|
||||
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "Un seul fichier .js, jusqu’à 1 Mio. Sa source est affichée ci-dessous avant l’envoi.",
|
||||
"Single .js file, up to 8 MiB. Its source is shown below before upload.": "Un seul fichier .js, jusqu’à 8 Mio. Sa source est affichée ci-dessous avant l’envoi.",
|
||||
"Single Key": "Clé unique",
|
||||
"Site & Branding": "Site et marque",
|
||||
"Site currency ({{currency}})": "Devise du site ({{currency}})",
|
||||
|
||||
@@ -4371,7 +4371,7 @@
|
||||
"Plugin sandbox": "プラグインサンドボックス",
|
||||
"Plugin saved, activation required": "プラグインを保存しました。有効化が必要です",
|
||||
"Plugin source": "プラグインソース",
|
||||
"Plugin source exceeds the 1 MiB limit.": "プラグインのソースが 1 MiB の上限を超えています。",
|
||||
"Plugin source exceeds the 8 MiB limit.": "プラグインのソースが 8 MiB の上限を超えています。",
|
||||
"Plugin uploaded successfully": "プラグインをアップロードしました",
|
||||
"Plugin version activated": "プラグインバージョンを有効化しました",
|
||||
"Plugin version deleted": "プラグインバージョンを削除しました",
|
||||
@@ -5429,7 +5429,7 @@
|
||||
"Simulation context is missing.": "シミュレーションに必要なコンテキストがありません。",
|
||||
"Simulation time": "シミュレーション時刻",
|
||||
"Since previous sample": "前回のサンプル以降",
|
||||
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "単一の .js ファイル、最大 1 MiB。アップロード前に下部でソースを確認できます。",
|
||||
"Single .js file, up to 8 MiB. Its source is shown below before upload.": "単一の .js ファイル、最大 8 MiB。アップロード前に下部でソースを確認できます。",
|
||||
"Single Key": "単一キー",
|
||||
"Site & Branding": "サイトとブランド",
|
||||
"Site currency ({{currency}})": "サイト通貨({{currency}})",
|
||||
|
||||
@@ -4371,7 +4371,7 @@
|
||||
"Plugin sandbox": "Песочница плагина",
|
||||
"Plugin saved, activation required": "Плагин сохранён, требуется активация",
|
||||
"Plugin source": "Исходный код плагина",
|
||||
"Plugin source exceeds the 1 MiB limit.": "Код плагина превышает лимит 1 МиБ.",
|
||||
"Plugin source exceeds the 8 MiB limit.": "Код плагина превышает лимит 8 МиБ.",
|
||||
"Plugin uploaded successfully": "Плагин успешно загружен",
|
||||
"Plugin version activated": "Версия плагина активирована",
|
||||
"Plugin version deleted": "Версия плагина удалена",
|
||||
@@ -5429,7 +5429,7 @@
|
||||
"Simulation context is missing.": "Отсутствует контекст для моделирования.",
|
||||
"Simulation time": "Время моделирования",
|
||||
"Since previous sample": "С предыдущей выборки",
|
||||
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "Один файл .js размером до 1 МиБ. Его исходный код показан ниже перед загрузкой.",
|
||||
"Single .js file, up to 8 MiB. Its source is shown below before upload.": "Один файл .js размером до 8 МиБ. Его исходный код показан ниже перед загрузкой.",
|
||||
"Single Key": "Одиночный ключ",
|
||||
"Site & Branding": "Сайт и брендинг",
|
||||
"Site currency ({{currency}})": "Валюта сайта ({{currency}})",
|
||||
|
||||
@@ -4371,7 +4371,7 @@
|
||||
"Plugin sandbox": "Môi trường thử nghiệm plugin",
|
||||
"Plugin saved, activation required": "Đã lưu plugin, cần kích hoạt",
|
||||
"Plugin source": "Mã nguồn plugin",
|
||||
"Plugin source exceeds the 1 MiB limit.": "Mã nguồn plugin vượt giới hạn 1 MiB.",
|
||||
"Plugin source exceeds the 8 MiB limit.": "Mã nguồn plugin vượt giới hạn 8 MiB.",
|
||||
"Plugin uploaded successfully": "Đã tải plugin lên",
|
||||
"Plugin version activated": "Đã kích hoạt phiên bản plugin",
|
||||
"Plugin version deleted": "Đã xóa phiên bản plugin",
|
||||
@@ -5429,7 +5429,7 @@
|
||||
"Simulation context is missing.": "Thiếu ngữ cảnh cần thiết để mô phỏng.",
|
||||
"Simulation time": "Thời gian mô phỏng",
|
||||
"Since previous sample": "Kể từ mẫu trước",
|
||||
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "Một tệp .js duy nhất, tối đa 1 MiB. Mã nguồn được hiển thị bên dưới trước khi tải lên.",
|
||||
"Single .js file, up to 8 MiB. Its source is shown below before upload.": "Một tệp .js duy nhất, tối đa 8 MiB. Mã nguồn được hiển thị bên dưới trước khi tải lên.",
|
||||
"Single Key": "Khóa đơn",
|
||||
"Site & Branding": "Trang web & thương hiệu",
|
||||
"Site currency ({{currency}})": "Tiền tệ trang web ({{currency}})",
|
||||
|
||||
@@ -4371,7 +4371,7 @@
|
||||
"Plugin sandbox": "外掛沙盒",
|
||||
"Plugin saved, activation required": "外掛已儲存,待啟用",
|
||||
"Plugin source": "外掛原始碼",
|
||||
"Plugin source exceeds the 1 MiB limit.": "外掛原始碼超過 1 MiB 上限。",
|
||||
"Plugin source exceeds the 8 MiB limit.": "外掛原始碼超過 8 MiB 上限。",
|
||||
"Plugin uploaded successfully": "外掛上傳成功",
|
||||
"Plugin version activated": "外掛版本已激活",
|
||||
"Plugin version deleted": "外掛版本已刪除",
|
||||
@@ -5429,7 +5429,7 @@
|
||||
"Simulation context is missing.": "缺少模擬所需的內容。",
|
||||
"Simulation time": "模擬時間",
|
||||
"Since previous sample": "自上次取樣以來",
|
||||
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "單一 .js 檔案,最大 1 MiB。上傳前會在下方顯示其原始碼。",
|
||||
"Single .js file, up to 8 MiB. Its source is shown below before upload.": "單一 .js 檔案,最大 8 MiB。上傳前會在下方顯示其原始碼。",
|
||||
"Single Key": "單金鑰",
|
||||
"Site & Branding": "站點與品牌",
|
||||
"Site currency ({{currency}})": "站點貨幣({{currency}})",
|
||||
|
||||
@@ -4371,7 +4371,7 @@
|
||||
"Plugin sandbox": "插件沙盒",
|
||||
"Plugin saved, activation required": "插件已保存,待激活",
|
||||
"Plugin source": "插件源码",
|
||||
"Plugin source exceeds the 1 MiB limit.": "插件源码超过 1 MiB 上限。",
|
||||
"Plugin source exceeds the 8 MiB limit.": "插件源码超过 8 MiB 上限。",
|
||||
"Plugin uploaded successfully": "插件上传成功",
|
||||
"Plugin version activated": "插件版本已激活",
|
||||
"Plugin version deleted": "插件版本已删除",
|
||||
@@ -5429,7 +5429,7 @@
|
||||
"Simulation context is missing.": "缺少模拟所需的上下文。",
|
||||
"Simulation time": "模拟时间",
|
||||
"Since previous sample": "自上次采样以来",
|
||||
"Single .js file, up to 1 MiB. Its source is shown below before upload.": "单个 .js 文件,最大 1 MiB。上传前会在下方显示其源码。",
|
||||
"Single .js file, up to 8 MiB. Its source is shown below before upload.": "单个 .js 文件,最大 8 MiB。上传前会在下方显示其源码。",
|
||||
"Single Key": "单密钥",
|
||||
"Site & Branding": "站点与品牌",
|
||||
"Site currency ({{currency}})": "站点货币({{currency}})",
|
||||
|
||||
Reference in New Issue
Block a user