feat(plugins): 插件「管理面板声明」库——声明模型 + 闭集校验 + catalog(design/09 §5.2/§5.2.1)
纯库、零接线:不碰 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」打不红,正是因为全序与算法无关;这不是测试虚护。)
This commit is contained in:
1 parent
6e28b38b08
commit
c9144c1096
19 files changed
+3053
No files matched your search
@@ -0,0 +1,182 @@
|
||||
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) }
|
||||
Reference in new issue
Block a user