diff --git a/internal/api/script/script.go b/internal/api/script/script.go index d0ed7a1..69b1d7d 100644 --- a/internal/api/script/script.go +++ b/internal/api/script/script.go @@ -84,11 +84,11 @@ type CreateRequest struct { mux.Meta `path:"/scripts" method:"POST"` Content string `form:"content" binding:"required,max=102400" label:"脚本详细描述"` Code string `form:"code" binding:"required,max=10485760" label:"脚本代码"` - Name string `form:"name" binding:"max=128" label:"库的名字"` - Description string `form:"description" binding:"max=10240" label:"库的描述"` + Name string `form:"name" binding:"max=50" label:"库的名字"` + Description string `form:"description" binding:"max=200" label:"库的描述"` Definition string `form:"definition" binding:"max=10240" label:"库的定义文件"` Version string `form:"version" binding:"max=32" label:"库的版本"` - Tags []string `form:"tags" binding:"omitempty,max=64" label:"标签"` // 标签,只有脚本类型为库时才有意义 + Tags []string `form:"tags" binding:"omitempty,max=5" label:"标签"` // 标签,只有脚本类型为库时才有意义 CategoryID int64 `form:"category" binding:"omitempty,numeric" label:"分类ID"` // 分类ID Type script_entity.Type `form:"type" binding:"required,oneof=1 2 3" label:"脚本类型"` // 脚本类型:1 用户脚本 2 订阅脚本(不支持) 3 脚本引用库 Public script_entity.Public `form:"public" binding:"required,oneof=1 2 3" label:"公开类型"` // 公开类型:1 公开 2 半公开 3 私有 @@ -120,7 +120,7 @@ type UpdateCodeRequest struct { //Name string `form:"name" binding:"max=128" label:"库的名字"` //Description string `form:"description" binding:"max=102400" label:"库的描述"` Version string `binding:"required,max=128" form:"version" label:"库的版本号"` - Tags []string `form:"tags" binding:"omitempty,max=64" label:"标签"` // 标签,只有脚本类型为库时才有意义 + Tags []string `form:"tags" binding:"omitempty,max=5" label:"标签"` // 标签,只有脚本类型为库时才有意义 Content string `binding:"required,max=102400" form:"content" label:"脚本详细描述"` Code string `binding:"required,max=10485760" form:"code" label:"脚本代码"` Definition string `binding:"max=102400" form:"definition" label:"库的定义文件"` @@ -260,8 +260,8 @@ type GetSettingResponse struct { type UpdateSettingRequest struct { mux.Meta `path:"/scripts/:id/setting" method:"PUT"` ID int64 `uri:"id" binding:"required"` - Name string `json:"name" binding:"max=128" label:"库的名字"` - Description string `json:"description" binding:"max=102400" label:"库的描述"` + Name string `json:"name" binding:"max=50" label:"库的名字"` + Description string `json:"description" binding:"max=200" label:"库的描述"` SyncUrl string `json:"sync_url" binding:"omitempty,url,max=1024" label:"代码同步url"` ContentUrl string `json:"content_url" binding:"omitempty,url,max=1024" label:"详细描述同步url"` DefinitionUrl string `json:"definition_url" binding:"omitempty,url,max=1024" label:"定义文件同步url"` @@ -276,8 +276,8 @@ type UpdateSettingResponse struct { // UpdateLibInfoRequest 更新库信息 type UpdateLibInfoRequest struct { mux.Meta `path:"/scripts/:id/lib-info" method:"PUT"` - Name string `json:"name" binding:"max=128" label:"库的名字"` - Description string `json:"description" binding:"max=102400" label:"库的描述"` + Name string `json:"name" binding:"max=50" label:"库的名字"` + Description string `json:"description" binding:"max=200" label:"库的描述"` } type UpdateLibInfoResponse struct { diff --git a/internal/pkg/code/code.go b/internal/pkg/code/code.go index 8b8bc0a..6c80635 100644 --- a/internal/pkg/code/code.go +++ b/internal/pkg/code/code.go @@ -38,6 +38,11 @@ const ( ScriptDeleteReleaseNotLatest ScriptCategoryNotFound + ScriptNameTooLong + ScriptDescTooLong + ScriptTagsTooMany + ScriptNameInvalid + ScriptDescInvalid ) // issue diff --git a/internal/pkg/code/zh_cn.go b/internal/pkg/code/zh_cn.go index 5f288e4..54d781a 100644 --- a/internal/pkg/code/zh_cn.go +++ b/internal/pkg/code/zh_cn.go @@ -39,6 +39,11 @@ var zhCN = map[int]string{ WebhookRepositoryNotFound: "仓库不存在", ScriptDeleteReleaseNotLatest: "删除发布版本失败,没有新的正式版本了", ScriptCategoryNotFound: "脚本分类不存在", + ScriptNameTooLong: "脚本名称过长,最多50个字符", + ScriptDescTooLong: "脚本描述过长,最多200个字符", + ScriptTagsTooMany: "标签数量过多,最多5个", + ScriptNameInvalid: "脚本名称格式无效,名称应为简单名称,不能包含换行符或逗号、竖线等分隔符", + ScriptDescInvalid: "脚本描述格式无效,描述应为一句话,不能包含换行符或多个句子", IssueLabelNotExist: "标签不存在", IssueNotFound: "反馈不存在", diff --git a/internal/service/script_svc/script.go b/internal/service/script_svc/script.go index d48b541..f1bc03e 100644 --- a/internal/service/script_svc/script.go +++ b/internal/service/script_svc/script.go @@ -8,9 +8,11 @@ import ( "errors" "fmt" "net/http" + "regexp" "strconv" "strings" "time" + "unicode/utf8" "github.com/scriptscat/scriptlist/internal/repository/issue_repo" "github.com/scriptscat/scriptlist/internal/repository/report_repo" @@ -302,6 +304,48 @@ func (s *scriptSvc) scriptCode(ctx context.Context, script *script_entity.Script return ret } +// nameInvalidRe rejects script names that contain SEO keyword-stuffing separators +// (commas, pipes, semicolons — in both ASCII and Chinese full-width forms) or newlines. +var nameInvalidRe = regexp.MustCompile(`[\r\n,,|;;]`) + +// multiSentenceRe detects descriptions that contain more than one sentence. +// It matches any of: +// - A newline character (\r or \n), which is not allowed in a one-sentence description. +// - A Chinese sentence-ending mark (。!?) followed by optional whitespace and then any +// non-whitespace character. Chinese punctuation is unambiguous as a sentence boundary so +// zero or more spaces before the next word are all covered: "第一句。第二句" and "第一句。 第二句". +// - An ASCII sentence-ending mark (.!?) followed by at least one space and then a capital +// ASCII letter or a CJK character. Requiring whitespace before the next word avoids false +// positives on abbreviations (e.g. "v1.0") and URLs. +var multiSentenceRe = regexp.MustCompile(`[\r\n]|[。!?]\s*\S|[.!?]\s+[A-Z\x{4e00}-\x{9fa5}]`) + +// validateScriptMeta validates the name, description, and tags extracted from script metadata. +// If nameUnchanged and descUnchanged are both true (i.e. neither field changed from the stored +// value), the length/format checks on name and description are skipped so existing scripts that +// pre-date these limits can still be updated without forcing the author to rename them. +func validateScriptMeta(ctx context.Context, name, description string, tags []string, nameUnchanged, descUnchanged bool) error { + if !nameUnchanged { + if nameInvalidRe.MatchString(name) { + return i18n.NewError(ctx, code.ScriptNameInvalid) + } + if utf8.RuneCountInString(name) > 50 { + return i18n.NewError(ctx, code.ScriptNameTooLong) + } + } + if !descUnchanged { + if multiSentenceRe.MatchString(description) { + return i18n.NewError(ctx, code.ScriptDescInvalid) + } + if utf8.RuneCountInString(description) > 200 { + return i18n.NewError(ctx, code.ScriptDescTooLong) + } + } + if len(tags) > 5 { + return i18n.NewError(ctx, code.ScriptTagsTooMany) + } + return nil +} + // Create 创建脚本 func (s *scriptSvc) Create(ctx context.Context, req *api.CreateRequest) (*api.CreateResponse, error) { script := &script_entity.Script{ @@ -353,6 +397,9 @@ func (s *scriptSvc) Create(ctx context.Context, req *api.CreateRequest) (*api.Cr script.Description = metaJson["description"][0] // 处理tag关联 tags = metaJson["tags"] + if err := validateScriptMeta(ctx, script.Name, script.Description, tags, false, false); err != nil { + return err + } if len(metaJson["background"]) > 0 || len(metaJson["crontab"]) > 0 { tags = append(tags, "后台脚本") } @@ -499,9 +546,14 @@ func (s *scriptSvc) UpdateCode(ctx context.Context, req *api.UpdateCodeRequest) } } // 更新名字和描述 + oldName, oldDescription := script.Name, script.Description script.Name = metaJson["name"][0] script.Description = metaJson["description"][0] tags = req.Tags + if err := validateScriptMeta(ctx, script.Name, script.Description, tags, + script.Name == oldName, script.Description == oldDescription); err != nil { + return nil, err + } if len(metaJson["background"]) > 0 || len(metaJson["crontab"]) > 0 { tags = append(tags, "后台脚本") } diff --git a/internal/service/script_svc/script_validate_test.go b/internal/service/script_svc/script_validate_test.go new file mode 100644 index 0000000..61d77f7 --- /dev/null +++ b/internal/service/script_svc/script_validate_test.go @@ -0,0 +1,230 @@ +package script_svc + +import ( + "context" + "testing" + + "github.com/cago-frame/cago/pkg/utils/httputils" + "github.com/scriptscat/scriptlist/internal/pkg/code" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertErrCode checks that err is a *httputils.Error with the expected code. +func assertErrCode(t *testing.T, err error, wantCode int) { + t.Helper() + require.Error(t, err) + var herr *httputils.Error + require.ErrorAs(t, err, &herr, "expected *httputils.Error") + assert.Equal(t, wantCode, herr.Code) +} + +func TestValidateScriptMeta(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + scriptName string + description string + tags []string + nameUnchanged bool + descUnchanged bool + wantErrCode int + }{ + // --- valid inputs --- + { + name: "valid simple name and single-sentence description", + scriptName: "My Script", + description: "A simple one-line description.", + tags: []string{"tag1", "tag2"}, + wantErrCode: 0, + }, + { + name: "valid name with hyphen", + scriptName: "Auto-Fill Script", + description: "一句简单的中文描述", + wantErrCode: 0, + }, + { + name: "valid description ending with Chinese period", + scriptName: "脚本名称", + description: "一段简单的脚本描述。", + wantErrCode: 0, + }, + + // --- name: newline --- + { + name: "name with LF newline", + scriptName: "My Script\nWith Newline", + description: "A simple description.", + wantErrCode: code.ScriptNameInvalid, + }, + { + name: "name with CR", + scriptName: "My Script\rWith CR", + description: "A simple description.", + wantErrCode: code.ScriptNameInvalid, + }, + + // --- name: SEO separator punctuation --- + { + name: "name with ASCII comma", + scriptName: "Script,keyword1,keyword2", + description: "A simple description.", + wantErrCode: code.ScriptNameInvalid, + }, + { + name: "name with Chinese full-width comma", + scriptName: "脚本名称,关键词1,关键词2", + description: "一段描述。", + wantErrCode: code.ScriptNameInvalid, + }, + { + name: "name with pipe", + scriptName: "Script | keyword1 | keyword2", + description: "A simple description.", + wantErrCode: code.ScriptNameInvalid, + }, + { + name: "name with ASCII semicolon", + scriptName: "Script;keyword1", + description: "A simple description.", + wantErrCode: code.ScriptNameInvalid, + }, + { + name: "name with Chinese full-width semicolon", + scriptName: "脚本;关键词", + description: "一段描述。", + wantErrCode: code.ScriptNameInvalid, + }, + + // --- name: length --- + { + name: "name exactly 51 runes", + scriptName: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxy", // 51 chars + description: "A simple description.", + wantErrCode: code.ScriptNameTooLong, + }, + { + name: "name exactly 50 runes", + scriptName: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwx", // 50 chars + description: "A simple description.", + wantErrCode: 0, + }, + + // --- description: newline --- + { + name: "description with LF newline", + scriptName: "My Script", + description: "First line.\nSecond line.", + wantErrCode: code.ScriptDescInvalid, + }, + { + name: "description with CR", + scriptName: "My Script", + description: "First line.\rSecond line.", + wantErrCode: code.ScriptDescInvalid, + }, + + // --- description: multiple Chinese sentences --- + { + name: "description with two Chinese sentences", + scriptName: "脚本名称", + description: "第一句话。第二句话。", + wantErrCode: code.ScriptDescInvalid, + }, + { + name: "description with two Chinese sentences separated by space", + scriptName: "脚本名称", + description: "第一句。 第二句。", + wantErrCode: code.ScriptDescInvalid, + }, + { + name: "description with Chinese exclamation in middle", + scriptName: "脚本名称", + description: "第一句!第二句。", + wantErrCode: code.ScriptDescInvalid, + }, + { + name: "description with Chinese question in middle", + scriptName: "脚本名称", + description: "第一句?第二句。", + wantErrCode: code.ScriptDescInvalid, + }, + + // --- description: multiple English sentences --- + { + name: "description with two English sentences", + scriptName: "My Script", + description: "First sentence. Second sentence.", + wantErrCode: code.ScriptDescInvalid, + }, + + // --- description: length --- + { + name: "description too long (>200 runes)", + scriptName: "My Script", + description: "这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述这是一段非常长的描述还要再多一点", + wantErrCode: code.ScriptDescTooLong, + }, + + // --- tags --- + { + name: "too many tags", + scriptName: "My Script", + description: "A simple description.", + tags: []string{"t1", "t2", "t3", "t4", "t5", "t6"}, + wantErrCode: code.ScriptTagsTooMany, + }, + { + name: "exactly 5 tags", + scriptName: "My Script", + description: "A simple description.", + tags: []string{"t1", "t2", "t3", "t4", "t5"}, + wantErrCode: 0, + }, + + // --- skip on unchanged --- + { + name: "skip name validation when name unchanged", + scriptName: "Old Name,keyword", + description: "A simple description.", + nameUnchanged: true, + wantErrCode: 0, + }, + { + name: "skip desc validation when desc unchanged", + scriptName: "My Script", + description: "第一句。第二句。", + descUnchanged: true, + wantErrCode: 0, + }, + { + name: "still validate tags when name and desc unchanged", + scriptName: "My Script", + description: "A simple description.", + tags: []string{"t1", "t2", "t3", "t4", "t5", "t6"}, + nameUnchanged: true, + descUnchanged: true, + wantErrCode: code.ScriptTagsTooMany, + }, + { + name: "do not skip desc when only name unchanged", + scriptName: "My Script", + description: "第一句。第二句。", + nameUnchanged: true, + wantErrCode: code.ScriptDescInvalid, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateScriptMeta(ctx, tt.scriptName, tt.description, tt.tags, tt.nameUnchanged, tt.descUnchanged) + if tt.wantErrCode == 0 { + assert.NoError(t, err) + } else { + assertErrCode(t, err, tt.wantErrCode) + } + }) + } +}