package paneldecl import ( "fmt" "regexp" "strings" "unicode/utf8" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件是「形状」层:字符集、长度上限、以及 §5.2.1 接入点表的键约束。 // // 为什么要限定字符集而不是照单全收:面板 id 与插件 id 要拼成路由 // /admin/plugin//(§5.7 C)与端点 /api/v1/admin/plugins/{id}/actions/{action} // (§5.2.1),「能直接当路径段用」是硬要求;图标名与动作名则要防「声明里塞 URL/SVG」 // (§5.4.4 不给插件开 SSRF 面)。 // 长度上限。都是「撑坏 Miuix 布局」与「URL/日志行不可读」两类实际后果倒推出来的,不是随手取的数。 const ( // idMaxLen 是 id 长度上限。既有插件 id 是 32 位十六进制(runtimeconfig.NewID), // 面板/区块 id 是短名字,64 给前者留了一倍余量。 idMaxLen = 64 // titleMaxRunes 是标题/标签文案上限。侧边栏与页面壳的排版宽度决定它,超出即截断, // 200 个字符足够任何语言的正常标题。 titleMaxRunes = 200 // hintMaxRunes 是 placeholder / description 这类辅助文案上限,比标题宽一档 // (接口地址、说明段落会更长)。 hintMaxRunes = 500 // iconMaxLen 是图标名上限。宿主内置图标名的量级是十几个字符,64 已极宽。 iconMaxLen = 64 // actionMaxLen 是动作名上限。示例 plugin.reauthProvider 是 21 字符,64 足够。 actionMaxLen = 64 // maxLogLimit 是 log 区块 `limit` 的上限。log 是「只读最近 N 条」的排障卡, // 一次拉上万条会把页面卡死;1000 与既有日志尾读的量级一致。 maxLogLimit = 1000 ) // 编译期正则。包级 var 存的是**不可变**的编译产物(与 internal/runtime/memory、 // internal/runtime/tool 等既有文件同一手法),不是可变全局状态。 var ( // idRe 是 id(插件 / 面板 / 区块 / 行内动作)的形状:小写字母数字,可含中划线分段。 // 依据 §5.2 示例 web-search / providers / add / list / test / reauth,并禁止大小写混用 // (URL 路径段大小写敏感,混用会引入难查的 404)。 idRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) // fieldKeyRe 是字段 key 的形状:§5.2 示例是 provider / apiKey / endpoint —— 小驼峰, // 因为它同时是**配置键**(写进插件配置段),比 id 宽一档:允许大写与下划线。 fieldKeyRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*$`) // optionsFromRe 是下拉选项来源:只允许 `plugin:` 前缀的名字(§5.2 示例 plugin:providers)。 // 禁止 `/`、第二个 `:` 与空白 ⇒ "http://…" 这类 URL 声明在校验期就被拒。 optionsFromRe = regexp.MustCompile(`^plugin:[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$`) // actionRe 是动作名:§5.2 示例 plugin.saveProvider / plugin.listProviders / plugin.testProvider。 // 允许点分名,**禁止** `/` 与 `:` —— 动作只是名字,不是 URL(§5.4.4)。 actionRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$`) // iconRe 是图标名:宿主内置图标表的名字,禁止任何标点(尤其 `<` / `{` / `;`), // 顺带堵死「把 SVG/CSS 片段塞进 icon」这条路。 iconRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]*$`) ) // requirement 是某个键在某种 section 类型上的必要性。 type requirement uint8 const ( // notAllowed:该键在这类区块上没有意义,出现即形状错误。 notAllowed requirement = iota // optional:可以出现,可以不出现。 optional // required:必须出现(缺 ⇒ 必填缺失)。 required // writeForbidden:这是**写面**键,出现在只读区块上即越界(§5.4.1 写动作一律 owner/admin)。 writeForbidden ) // sectionShape 是 §5.2.1 接入点表的可执行形式:每种区块消费哪些键、哪些是写面。 // // 为什么要把这张表写成硬校验:宿主是「按声明泛化渲染」的,一个 form 没有 submit、 // 一个 table 没有 columns,到前端就是**半渲染**(区块在、功能不在)—— 正是 §5.2.1 // 点名要防的。所以形状错误在声明期整体拒掉该面板,并显式报错。 type sectionShape struct { fields requirement submit requirement source requirement columns requirement rowActions requirement limit requirement } // shapeOf 返回某类区块的键约束(§5.2.1 的 section.form / section.table / section.status / // section.log 四行)。写成函数而不是包级 map:本仓禁包级可变全局,且 switch 让「新增 // section 类型忘了补形状」在 review 时一眼可见。 func shapeOf(t sdk.SectionType) sectionShape { switch t { case sdk.SectionForm: return sectionShape{fields: required, submit: required} case sdk.SectionTable: return sectionShape{ columns: required, source: required, rowActions: optional, submit: writeForbidden, } case sdk.SectionStatus: return sectionShape{ fields: required, submit: writeForbidden, rowActions: writeForbidden, } case sdk.SectionLog: return sectionShape{ source: required, limit: optional, submit: writeForbidden, rowActions: writeForbidden, } default: return sectionShape{} } } // normalizeGroup 把空分组按 §5.2「本期固定 plugins」回落到唯一分组。 // // 为什么把空值当「用默认」而不是「必填缺失」:分组本期只有闭集里的一个成员,缺省不存在 // 歧义;而四处要求插件作者重复写同一个常量只会制造漏写。非空但不是闭集成员的仍然拒绝。 func normalizeGroup(g sdk.GroupID) sdk.GroupID { if strings.TrimSpace(string(g)) == "" { return sdk.GroupPlugins } return g } // validID 报告 id 是否满足路径段形状。 func validID(id string) bool { return len(id) > 0 && len(id) <= idMaxLen && idRe.MatchString(id) } // validFieldKey 报告字段 key 是否满足「配置键」形状。 func validFieldKey(key string) bool { return key != "" && fieldKeyRe.MatchString(key) } // validAction 报告动作名是否合法。 func validAction(action string) bool { return len(action) > 0 && len(action) <= actionMaxLen && actionRe.MatchString(action) } // validIcon 报告图标名是否合法。 func validIcon(icon string) bool { return len(icon) > 0 && len(icon) <= iconMaxLen && iconRe.MatchString(icon) } // checkOptionalText 校验一段可选文案:允许缺省,但不允许「只有空白」、控制字符、 // 或超过 maxRunes(长度超限会把侧边栏/页面壳排版撑坏,属实际后果而非洁癖)。 func checkOptionalText(value, path string, maxRunes int) []Violation { if value == "" { return nil } trimmed := strings.TrimSpace(value) if trimmed == "" { return []Violation{violation(ErrInvalidText, path, "文案不能只有空白字符")} } if n := utf8.RuneCountInString(trimmed); n > maxRunes { return []Violation{violation(ErrInvalidText, path, fmt.Sprintf("文案长度 %d 超过上限 %d", n, maxRunes))} } if r := firstControlRune(trimmed); r >= 0 { return []Violation{violation(ErrInvalidText, path, fmt.Sprintf("文案含控制字符 %q(前端渲染与日志都会被污染)", r))} } return nil } // checkRequiredText 校验一段必填文案:缺省即必填缺失,其余同 checkOptionalText。 func checkRequiredText(value, path string, maxRunes int) []Violation { if strings.TrimSpace(value) == "" { return []Violation{violation(ErrMissingField, path, "必填文案缺失或全为空白")} } return checkOptionalText(value, path, maxRunes) } // firstControlRune 返回第一个控制字符,没有则 -1。 func firstControlRune(s string) rune { for _, r := range s { if r < 0x20 || r == 0x7f { return r } } return -1 } // panelPath 是面板在声明里的索引式路径前缀(§5.2 的 adminPanels 数组)。 func panelPath(i int) string { return fmt.Sprintf("adminPanels[%d]", i) }