Skip to content
16 changes: 8 additions & 8 deletions internal/api/script/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 私有
Expand Down Expand Up @@ -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:"库的定义文件"`
Expand Down Expand Up @@ -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"`
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/pkg/code/code.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const (

ScriptDeleteReleaseNotLatest
ScriptCategoryNotFound
ScriptNameTooLong
ScriptDescTooLong
ScriptTagsTooMany
ScriptNameInvalid
ScriptDescInvalid
)

// issue
Expand Down
5 changes: 5 additions & 0 deletions internal/pkg/code/zh_cn.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ var zhCN = map[int]string{
WebhookRepositoryNotFound: "仓库不存在",
ScriptDeleteReleaseNotLatest: "删除发布版本失败,没有新的正式版本了",
ScriptCategoryNotFound: "脚本分类不存在",
ScriptNameTooLong: "脚本名称过长,最多50个字符",
ScriptDescTooLong: "脚本描述过长,最多200个字符",
ScriptTagsTooMany: "标签数量过多,最多5个",
ScriptNameInvalid: "脚本名称格式无效,名称应为简单名称,不能包含换行符或逗号、竖线等分隔符",
ScriptDescInvalid: "脚本描述格式无效,描述应为一句话,不能包含换行符或多个句子",

IssueLabelNotExist: "标签不存在",
IssueNotFound: "反馈不存在",
Expand Down
52 changes: 52 additions & 0 deletions internal/service/script_svc/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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, "后台脚本")
}
Expand Down Expand Up @@ -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, "后台脚本")
}
Expand Down
230 changes: 230 additions & 0 deletions internal/service/script_svc/script_validate_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}