纯库、零接线:不碰 gateway/frontend/Docs/.env*(本片刻意不接线,避免与在途切片争用文件面)。 - pkg/plugins/sdk:面板声明类型(JSON tag 与 design/09 §5.2 示例逐字同形); 字段类型与 section 类型都是**闭集**;When 三态 + **严格 UnmarshalJSON**(未知条件键报错, 不静默丢) - internal/capability/paneldecl:闭集校验(47 条拒绝用例,断言的是**分类哨兵 + JSON 路径**, 不是错误字符串)· catalog(并发安全、返回拷贝、按 (group, order, 登记序号) 的**全序**稳定排序)· when 求值(上下文由 Resolver 注入,本包不读环境变量/文件)· 角色过滤(viewer 只留 status/log/table 且摘掉 rowActions,纯写面板整体隐藏;未知角色 fail-closed) - 拒绝粒度:插件 id 非法 ⇒ 整插件拒绝;单个面板非法 ⇒ 只拒该面板 + 聚合显式报错(理由见报告) 【我的验收】隔离 worktree(19 文件)build/vet/linux/darwin 全过、69 包全绿; 我亲手打红变异:把同组同序的回退比较 反成 ⇒ TestCatalog_StableOrder FAIL。 (记录一条观察:比较器已靠 seq 构成全序,故 sort.SliceStable 的稳定性是冗余保护 —— 我第一次用「SliceStable→Slice」打不红,正是因为全序与算法无关;这不是测试虚护。)
183 lines
7.9 KiB
Go
183 lines
7.9 KiB
Go
package paneldecl
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
|
)
|
|
|
|
// 本文件是「形状」层:字符集、长度上限、以及 §5.2.1 接入点表的键约束。
|
|
//
|
|
// 为什么要限定字符集而不是照单全收:面板 id 与插件 id 要拼成路由
|
|
// /admin/plugin/<pluginId>/<panelId>(§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) }
|