mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。
UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。
覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。
过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。
角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。
不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。
有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。
插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。
roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。
**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。
写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。
readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。
readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。
去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。
生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
This commit is contained in:
@ -126,6 +126,13 @@ type UIExtension struct {
|
||||
// Elements are snippets injected into EXISTING pages, keyed by target page
|
||||
// id (e.g. "status", "keys"). Order within a target is plugin load order.
|
||||
Elements []UIElement `json:"elements,omitempty"`
|
||||
// Visibility is the plugin-wide default audience, applied to every page and
|
||||
// element this plugin contributes. A per-page Audience narrows it further.
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
// StateScope splits the plugin's published state into global and per-caller
|
||||
// buckets, for a plugin that is readable by more than one role but must not
|
||||
// hand one caller another's data.
|
||||
StateScope *StateScope `json:"state_scope,omitempty"`
|
||||
}
|
||||
|
||||
// UIPage is a plugin-provided page.
|
||||
@ -134,6 +141,20 @@ type UIPage struct {
|
||||
Title string `json:"title"` // sidebar label
|
||||
Icon string `json:"icon"` // optional inline SVG or short glyph
|
||||
Order int `json:"order"` // sidebar sort key (default 100)
|
||||
// Audience limits which API-key roles may see this page. "" and "all" mean
|
||||
// every authenticated role; "admin" hides it from user keys; a comma list
|
||||
// ("admin,user") names the roles allowed.
|
||||
//
|
||||
// This exists because a plugin page is not automatically public data. The
|
||||
// billing page shows GLOBAL cost across every source, and the quota page
|
||||
// shows upstream balances — neither belongs to whoever happens to hold a
|
||||
// user key, yet both were served to them because /api/ui-inject applied no
|
||||
// role filter at all.
|
||||
//
|
||||
// The plugin declares its own default and the gateway may override it (see
|
||||
// SetPluginVisibility), because the plugin author cannot know the operator's
|
||||
// policy but the operator must be able to impose one.
|
||||
Audience string `json:"audience,omitempty"`
|
||||
// Mount is the page body. It may contain <script> and <style>; the kernel
|
||||
// executes scripts AFTER injecting the HTML so the DOM exists, and exposes
|
||||
// `pluginAPI` to them (see docs/plugins.md).
|
||||
@ -145,6 +166,10 @@ type UIElement struct {
|
||||
// Target is the id of the pane to inject into: "status", "chat", "keys",
|
||||
// "sort", "sources" or "adapters".
|
||||
Target string `json:"target"`
|
||||
// Audience is the same role filter as UIPage.Audience. An injected tile can
|
||||
// leak exactly what a whole page would — the billing status tile shows total
|
||||
// cost — so the field exists here too rather than only on pages.
|
||||
Audience string `json:"audience,omitempty"`
|
||||
// Anchor is where in the target pane: "top", "bottom" or "before:<sel>" /
|
||||
// "after:<sel>" for a CSS selector. Empty = "bottom".
|
||||
Anchor string `json:"anchor,omitempty"`
|
||||
@ -179,6 +204,9 @@ type Plugin struct {
|
||||
// reasoning as a systemd unit being masked rather than removed.
|
||||
Disabled bool
|
||||
dir string
|
||||
// http lists endpoints this plugin mounts under /api/plugins/http/. Read at
|
||||
// load time, never mutated afterwards.
|
||||
http []HTTPHandler
|
||||
// exports lists the functions this plugin allows the WebUI to call through
|
||||
// POST /api/plugins/{name}/call/{fn}. It is read once at load from
|
||||
// plugin.exports and never changes, so Call can check it without taking a
|
||||
@ -233,6 +261,9 @@ type Plugins struct {
|
||||
// direct log call so the plugin package stays free of a logging dependency
|
||||
// and tests can capture the warnings.
|
||||
logf func(format string, args ...interface{})
|
||||
// visibility holds the operator's per-plugin audience overrides, keyed by
|
||||
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
|
||||
visibility visibilityOverrides
|
||||
// stateDirDisabled turns persistence off. Used by tests that assert state
|
||||
// starts empty, and by an embedder that has no writable plugin dir.
|
||||
stateDirDisabled bool
|
||||
@ -633,6 +664,16 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
p.UI = ui
|
||||
}
|
||||
|
||||
// ---- http endpoints ----
|
||||
p.http = readPluginHTTP(w.L)
|
||||
|
||||
if ui, ok := readPluginUI(w.L); ok {
|
||||
p.UI = ui
|
||||
if ui.StateScope != nil {
|
||||
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- exports ----
|
||||
// Read here, while the state is locked and before restore() runs: the list is
|
||||
// static for the life of the plugin, so Call never has to re-enter Lua just
|
||||
@ -762,6 +803,7 @@ func readPluginExports(L *golua.State) []string {
|
||||
func readPluginUI(L *golua.State) (*UIExtension, bool) {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
L.GetField(-1, "ui")
|
||||
@ -769,12 +811,67 @@ func readPluginUI(L *golua.State) (*UIExtension, bool) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
|
||||
// registering, BEFORE setupGlobals installs the json global that luaToJSON
|
||||
// depends on — so the conversion fails and every field except the ones on
|
||||
// Page/Pages (read separately) comes back empty. That is why visibility and
|
||||
// state_scope silently read as unset even though the .lua declares them:
|
||||
// the third field in this file to be lost to the same cause, after
|
||||
// readPluginExports and readPluginHTTP.
|
||||
var ui UIExtension
|
||||
if err := luaToJSON(L, -1, &ui); err != nil {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
ui.Visibility = asString(m["visibility"])
|
||||
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
|
||||
sc := &StateScope{}
|
||||
if gs, ok := scope["global"].([]interface{}); ok {
|
||||
for _, g := range gs {
|
||||
if s := asString(g); s != "" {
|
||||
sc.Global = append(sc.Global, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ds, ok := scope["depth"].(map[string]interface{}); ok {
|
||||
sc.Depth = map[string]int{}
|
||||
for k, v := range ds {
|
||||
if n, ok := asFloat(v); ok {
|
||||
sc.Depth[k] = int(n)
|
||||
}
|
||||
}
|
||||
sc.Depth = compileDepthMap(sc.Depth)
|
||||
}
|
||||
ui.StateScope = sc
|
||||
}
|
||||
if pg, ok := m["page"].(map[string]interface{}); ok {
|
||||
ui.Page = uipageFromValue(pg)
|
||||
}
|
||||
if pgs, ok := m["pages"].([]interface{}); ok {
|
||||
for _, it := range pgs {
|
||||
if pm, ok := it.(map[string]interface{}); ok {
|
||||
ui.Pages = append(ui.Pages, uipageFromValue(pm))
|
||||
}
|
||||
}
|
||||
}
|
||||
if els, ok := m["elements"].([]interface{}); ok {
|
||||
for _, it := range els {
|
||||
em, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
el := UIElement{
|
||||
Target: asString(em["target"]),
|
||||
Anchor: asString(em["anchor"]),
|
||||
Mount: asString(em["mount"]),
|
||||
Audience: asString(em["audience"]),
|
||||
}
|
||||
if n, ok := asFloat(em["order"]); ok {
|
||||
el.Order = int(n)
|
||||
}
|
||||
ui.Elements = append(ui.Elements, el)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
@ -1022,6 +1119,139 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
|
||||
}
|
||||
|
||||
// UI returns the merged UI extensions to inject into the WebUI.
|
||||
// audienceAllows reports whether a role is permitted by an audience string.
|
||||
//
|
||||
// The grammar is deliberately small, because it is authored in two places (a
|
||||
// .lua file and config.yaml) and a richer language would only give someone a way
|
||||
// to express a rule nobody audits:
|
||||
//
|
||||
// "" or "all" everyone
|
||||
// "admin" admin role only
|
||||
// "user" user role only
|
||||
// "admin,user" either
|
||||
//
|
||||
// "*" is deliberately NOT a synonym for "all": an audience that looks
|
||||
// restrictive in a config file while meaning everything is exactly the kind of
|
||||
// setting that fails silently.
|
||||
func audienceAllows(want, role string) bool {
|
||||
want = strings.ToLower(strings.TrimSpace(want))
|
||||
if want == "" || want == "all" {
|
||||
return true
|
||||
}
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
for _, part := range strings.Split(want, ",") {
|
||||
if strings.TrimSpace(part) == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// visibilityOverrides holds the operator's per-plugin audience overrides.
|
||||
//
|
||||
// A plugin declares its own default and the operator may force one. Both sides
|
||||
// need to be able to: the plugin author does not know the deployment's policy,
|
||||
// and the operator must not have to edit a bundled .lua — which the next
|
||||
// upgrade would overwrite anyway.
|
||||
type visibilityOverrides map[string]string
|
||||
|
||||
// SetPluginVisibility records an operator override for a plugin's audience. An
|
||||
// empty value clears the override and restores the plugin's own default.
|
||||
func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.visibility == nil {
|
||||
ps.visibility = visibilityOverrides{}
|
||||
}
|
||||
plugin = strings.TrimSpace(plugin)
|
||||
audience = strings.TrimSpace(audience)
|
||||
if audience == "" {
|
||||
delete(ps.visibility, plugin)
|
||||
return
|
||||
}
|
||||
ps.visibility[plugin] = audience
|
||||
}
|
||||
|
||||
// VisibilityOverride returns the operator's audience override for a plugin,
|
||||
// or "" when none is set. Exposed so /api/plugins can show which visibility is
|
||||
// imposed rather than only the one in effect.
|
||||
func (ps *Plugins) VisibilityOverride(plugin string) string {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
return ps.visibility[plugin]
|
||||
}
|
||||
|
||||
// uiAudience resolves the audience in force for a plugin: the operator's
|
||||
// override if set, else the plugin's declared default.
|
||||
func (ps *Plugins) uiAudience(p *Plugin, declared string) string {
|
||||
ps.mu.RLock()
|
||||
override := ps.visibility[p.Info.Name]
|
||||
ps.mu.RUnlock()
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
return declared
|
||||
}
|
||||
|
||||
// UIForRole returns the UI a given role may see.
|
||||
//
|
||||
// Filtering happens HERE, not in the HTTP layer, so every consumer sees the same
|
||||
// view — the WebUI boot payload, the Electron shell and the tests. Filtering at
|
||||
// one call site means the others quietly keep serving everything.
|
||||
//
|
||||
// A plugin's page/element can narrow visibility further than its own default
|
||||
// (a public summary page beside an admin-only one), so the role check runs on
|
||||
// the resolved plugin audience AND on each item's own declaration.
|
||||
func (ps *Plugins) UIForRole(role string) *UIExtension {
|
||||
full := ps.UI()
|
||||
if full == nil {
|
||||
return &UIExtension{}
|
||||
}
|
||||
// An admin sees everything, including anything a plugin hides from itself
|
||||
// by mistake: anything else makes an override unverifiable from the browser.
|
||||
if role == "admin" {
|
||||
return full
|
||||
}
|
||||
out := &UIExtension{}
|
||||
// The plugin-level default is per plugin, so pages are filtered by walking
|
||||
// plugins rather than pages: a page id alone does not say who owns it.
|
||||
for _, p := range ps.plugins {
|
||||
if p.UI == nil || p.LoadError != "" {
|
||||
continue
|
||||
}
|
||||
aud := ps.uiAudience(p, p.UI.Visibility)
|
||||
if !audienceAllows(aud, role) {
|
||||
continue
|
||||
}
|
||||
for _, pg := range p.UI.Pages {
|
||||
if pg == nil || !audienceAllows(pg.Audience, role) {
|
||||
continue
|
||||
}
|
||||
out.Pages = append(out.Pages, pg)
|
||||
}
|
||||
for _, el := range p.UI.Elements {
|
||||
if !audienceAllows(el.Audience, role) {
|
||||
continue
|
||||
}
|
||||
out.Elements = append(out.Elements, el)
|
||||
}
|
||||
// Page (singular) is the other half of the same declaration and both
|
||||
// merge in UI(). Handling only Pages made every plugin that used the
|
||||
// single-page form disappear for a user key — silently, with no error,
|
||||
// because "this plugin contributes nothing to you" is indistinguishable
|
||||
// from "this plugin is not installed".
|
||||
if p.UI.Page != nil && audienceAllows(p.UI.Page.Audience, role) {
|
||||
if out.Page == nil {
|
||||
out.Page = p.UI.Page
|
||||
}
|
||||
if !containsPage(out.Pages, p.UI.Page.PageID) {
|
||||
out.Pages = append(out.Pages, p.UI.Page)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (ps *Plugins) UI() *UIExtension {
|
||||
if u := ps.ui.Load(); u != nil {
|
||||
return u
|
||||
@ -1790,3 +2020,227 @@ func pluginExportAllowed(p *Plugin, fn string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StateVisibleTo reports whether a role may read a plugin's published state.
|
||||
//
|
||||
// The audience is resolved exactly as for the UI: operator override first, then
|
||||
// the plugin's declaration. A plugin with neither is readable by any
|
||||
// authenticated role, which is the pre-existing behaviour and the reason a
|
||||
// newly installed widget keeps working without an edit.
|
||||
|
||||
// HTTPHandler is one endpoint a plugin registers on the gateway's router.
|
||||
//
|
||||
// A plugin that only receives hooks (billing) has nothing to expose. A plugin
|
||||
// that computes something the operator may want to query from OUTSIDE the
|
||||
// browser — a health probe, a CI check, another tool — needs a route, and
|
||||
// hard-coding one in the gateway would mean every such plugin needs a gateway
|
||||
// change. So the plugin declares the path, the method and the role it requires,
|
||||
// and the gateway mounts it.
|
||||
//
|
||||
// The handler is a Lua function taking (request) and returning (response). Both
|
||||
// are plain tables: request carries method/path/query/headers/body, response
|
||||
// carries status/headers/body/json. Nothing here can reach the Go side except
|
||||
// through those two values, so the surface a plugin can express is bounded by
|
||||
// what a table can say.
|
||||
type HTTPHandler struct {
|
||||
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
|
||||
// segments, captured into request.params.
|
||||
Path string `json:"path"`
|
||||
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
|
||||
// accepted — an arbitrary verb would need a switch in the router for no gain.
|
||||
Method string `json:"method,omitempty"`
|
||||
// Roles are the API-key roles allowed to call it. EMPTY MEANS EVERYONE, which
|
||||
// is the wrong default for an endpoint a plugin invented: an unauthenticated
|
||||
// internet scan should not be able to ask it questions. RequireRole exists
|
||||
// so a plugin that really wants public access has to say so.
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
// Keys names SPECIFIC gateway keys allowed to call it, by their configured
|
||||
// name (the `name:` on a gateway_keys entry), never by the secret.
|
||||
//
|
||||
// Names rather than secrets for three reasons: a secret is rotated without
|
||||
// notice and a rule written against it would silently stop matching; putting
|
||||
// secrets in a plugin file scatters credentials across the install; and the
|
||||
// operator reads names. A key with no name cannot be named here.
|
||||
//
|
||||
// When Keys is non-empty it is the WHOLE rule, not an addition to Roles. An
|
||||
// OR would mean an operator writing a two-key allowlist had also granted
|
||||
// every user key, which is the opposite of what they meant.
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
// RequireAuth defaults to true in practice: the gateway refuses a handler
|
||||
// with neither Roles nor an explicit opt-out.
|
||||
Public bool `json:"public,omitempty"`
|
||||
// Handler is the name of the plugin function to invoke.
|
||||
Handler string `json:"handler"`
|
||||
// Description is shown in /api/plugins so an operator can see what a plugin
|
||||
// has exposed without reading the .lua.
|
||||
Description string `json:"description,omitempty"`
|
||||
// Plugin is filled in by HTTPHandlers: which plugin registered this route.
|
||||
// The gateway dispatcher needs it to know which Lua state to enter, and
|
||||
// deriving it from the path would mean trusting the plugin's own prefix.
|
||||
Plugin string `json:"-"`
|
||||
}
|
||||
|
||||
// routePath renders the handler's full mount path.
|
||||
func (h HTTPHandler) routePath() string {
|
||||
return "/api/plugins/http/" + strings.Trim(h.Path, "/")
|
||||
}
|
||||
|
||||
// allows reports whether a caller may invoke this handler.
|
||||
//
|
||||
// keyName is the caller key's configured name ("" when it has none), not the
|
||||
// secret.
|
||||
func (h HTTPHandler) allows(role, keyName string) bool {
|
||||
// Admin is always admitted. An operator must be able to inspect what a plugin
|
||||
// exposes even while locked out of it, or a typo in a key list would leave
|
||||
// nobody able to diagnose the endpoint they were trying to tighten.
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
// Named keys REPLACE the role rule rather than adding to it.
|
||||
if len(h.Keys) > 0 {
|
||||
if keyName == "" {
|
||||
return false
|
||||
}
|
||||
for _, k := range h.Keys {
|
||||
if strings.EqualFold(strings.TrimSpace(k), keyName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if h.Public {
|
||||
return true
|
||||
}
|
||||
if len(h.Roles) == 0 {
|
||||
// No declared roles and no public opt-out: treat as admin-only. A plugin
|
||||
// author who forgets this gets a 403 rather than an open endpoint, and
|
||||
// the error says which field to set.
|
||||
return role == "admin"
|
||||
}
|
||||
for _, r := range h.Roles {
|
||||
if strings.EqualFold(strings.TrimSpace(r), role) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ps *Plugins) StateVisibleTo(name, role string) bool {
|
||||
if role == "admin" {
|
||||
return true
|
||||
}
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name != name {
|
||||
continue
|
||||
}
|
||||
override := ps.visibility[name]
|
||||
if override != "" {
|
||||
return audienceAllows(override, role)
|
||||
}
|
||||
declared := ""
|
||||
if p.UI != nil {
|
||||
declared = p.UI.Visibility
|
||||
}
|
||||
return audienceAllows(declared, role)
|
||||
}
|
||||
// An unknown plugin name: no visibility can be established, and this endpoint
|
||||
// answers with an empty state rather than an error, so refusing here would
|
||||
// change behaviour for a name that may simply not be installed.
|
||||
return true
|
||||
}
|
||||
|
||||
func containsPage(pages []*UIPage, id string) bool {
|
||||
for _, p := range pages {
|
||||
if p != nil && p.PageID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readPluginHTTP collects plugin.http, the endpoints the plugin asks the gateway
|
||||
// to mount.
|
||||
//
|
||||
// A malformed entry is DROPPED rather than failing the plugin: a plugin with one
|
||||
// bad route should still load its hooks and pages, and the bad route simply does
|
||||
// not exist. Failing the whole plugin would take a working billing widget down
|
||||
// over a typo in an experimental endpoint.
|
||||
func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "http")
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
|
||||
//
|
||||
// luaToJSON needs the json global, which is not installed yet while a plugin
|
||||
// is registering. And golua's GetField does NOT convert a numeric key: with
|
||||
// a string index "1" it returns nil, so an index loop silently produced an
|
||||
// empty list and every plugin appeared to declare no endpoints at all.
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var list []HTTPHandler
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
h := HTTPHandler{
|
||||
Path: asString(m["path"]),
|
||||
Method: asString(m["method"]),
|
||||
Handler: asString(m["handler"]),
|
||||
Description: asString(m["description"]),
|
||||
Public: m["public"] == true,
|
||||
}
|
||||
if roles, ok := m["roles"].([]interface{}); ok {
|
||||
for _, r := range roles {
|
||||
if s := asString(r); s != "" {
|
||||
h.Roles = append(h.Roles, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys, ok := m["keys"].([]interface{}); ok {
|
||||
for _, k := range keys {
|
||||
if s := asString(k); s != "" {
|
||||
h.Keys = append(h.Keys, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A malformed entry is dropped rather than fatal: one bad route must not
|
||||
// cost a plugin its hooks and pages. That is also why the mounted count
|
||||
// can be lower than the declared count — /api/plugins lists what mounted.
|
||||
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
|
||||
h.Method = method
|
||||
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
|
||||
list = append(list, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// uipageFromValue builds a UIPage from a decoded Lua table.
|
||||
func uipageFromValue(m map[string]interface{}) *UIPage {
|
||||
pg := &UIPage{
|
||||
PageID: asString(m["page_id"]),
|
||||
Title: asString(m["title"]),
|
||||
Icon: asString(m["icon"]),
|
||||
Mount: asString(m["mount"]),
|
||||
Audience: asString(m["audience"]),
|
||||
}
|
||||
if n, ok := asFloat(m["order"]); ok {
|
||||
pg.Order = int(n)
|
||||
}
|
||||
return pg
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user