mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
插件页面不是自动公开的数据。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 层过滤意味着其它消费者照旧全量泄漏。
## state 分区:让"看自己的账单"成为可能
角色可见性回答不了"哪一部分"。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 正是这次要堵的泄露。
## 注册式 HTTP 端点:Go 监听,Lua 处理
有些东西值得从浏览器**外面**查(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>
492 lines
17 KiB
Go
492 lines
17 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"llmsproxy/internal/lua"
|
|
)
|
|
|
|
var (
|
|
errPluginName = errors.New("plugin name must be non-empty and contain no path separator or dot")
|
|
errNoPluginDir = errors.New("no plugin_dir configured; set plugin_dir in config.yaml to enable plugins")
|
|
)
|
|
|
|
// Plugin management API.
|
|
//
|
|
// GET /api/plugins list loaded plugins + their hooks/UI/errors
|
|
// POST /api/plugins upload/replace one plugin (.lua), hot-applied
|
|
// DELETE /api/plugins/{name} remove a plugin
|
|
// GET /api/plugins/{name}/state the plugin's own published state
|
|
// PUT /api/plugins/{name}/state replace that state (admin only)
|
|
//
|
|
// Why state is a first-class endpoint: request_end is the LAST stage, so a
|
|
// hook's return value has no downstream consumer inside the gateway. A plugin
|
|
// that accumulates numbers (the billing plugin does exactly this) therefore
|
|
// keeps them in its own Lua state and serves them here, which is what its UI
|
|
// component fetches. This keeps plugin data clearly separated from the
|
|
// gateway's own stats — see the accounting note in docs/plugins.md: the
|
|
// gateway's quota accounting stays authoritative, a plugin only reports.
|
|
|
|
// handlePluginUI serves the merged UI extensions the WebUI injects at boot.
|
|
//
|
|
// It is a single GET (not per-plugin) because the browser needs ALL extensions
|
|
// before it can build the sidebar: a page contributed by one plugin and an
|
|
// element contributed by another land in the same payload, and fetching them
|
|
// separately would mean the sidebar has to be rebuilt as each arrives.
|
|
//
|
|
// Served to any authenticated role: the WebUI is authenticated before it asks,
|
|
// and a plugin's own widgets need to render for the user whose key they show.
|
|
func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
ps := g.core.Plugins()
|
|
if ps == nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}})
|
|
return
|
|
}
|
|
// AllStages, not a hand-written list: an earlier version enumerated the
|
|
// three stages literally here, so when chain_step was added it was silently
|
|
// missing from this response — a plugin author reading the discovery
|
|
// document would have believed the stage did not exist. Deriving it from
|
|
// the one place that defines the order is the whole point of having it.
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ui": ps.UI(),
|
|
"stages": pipelineStageNames(),
|
|
})
|
|
}
|
|
|
|
// stageNames returns the pipeline stage names in firing order, for the
|
|
// discovery payload and for tests.
|
|
func pipelineStageNames() []string {
|
|
out := make([]string, 0, len(lua.AllStages))
|
|
for _, s := range lua.AllStages {
|
|
out = append(out, string(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
|
|
ps := g.core.Plugins()
|
|
if ps == nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": []interface{}{}})
|
|
return
|
|
}
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/plugins")
|
|
path = strings.Trim(path, "/")
|
|
|
|
// /api/plugins/{name}/state
|
|
if strings.HasSuffix(path, "/state") {
|
|
name := strings.TrimSuffix(path, "/state")
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
|
|
return
|
|
}
|
|
g.handlePluginState(w, r, name)
|
|
return
|
|
}
|
|
|
|
// /api/plugins/{name}/http/{path}/access — the operator's view of, and edit
|
|
// surface for, which keys an endpoint answers.
|
|
if i := strings.Index(path, "/http/"); i >= 0 {
|
|
name := path[:i]
|
|
rest := path[i+len("/http/"):]
|
|
if strings.HasSuffix(rest, "/access") {
|
|
epPath := strings.TrimSuffix(rest, "/access")
|
|
g.handleEndpointAccess(w, r, name, epPath)
|
|
return
|
|
}
|
|
}
|
|
|
|
// /api/plugins/{name}/call/{fn}
|
|
if i := strings.Index(path, "/call/"); i >= 0 {
|
|
name := path[:i]
|
|
fn := path[i+len("/call/"):]
|
|
if name == "" || fn == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name")
|
|
return
|
|
}
|
|
g.handlePluginCall(w, r, name, fn)
|
|
return
|
|
}
|
|
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
|
|
if path == "" {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"plugins": ps.List(),
|
|
"on_disk": ps.OnDisk(),
|
|
"hook_errors": ps.HookErrors(),
|
|
"plugin_dir": g.pluginDir(),
|
|
})
|
|
case http.MethodPost:
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if err := g.installPlugin(body.Name, body.Code); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
return
|
|
}
|
|
|
|
// /api/plugins/{name} — source read (GET), enable/disable (PUT), delete.
|
|
// Multi-segment paths (…/state) are handled earlier and never reach here.
|
|
if !strings.Contains(path, "/") {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// Reading the SOURCE (not the runtime state) is what an editor
|
|
// needs; the state endpoint is /state and returns accumulated data
|
|
// instead. Mixing the two would make a "save what I read"
|
|
// round-trip impossible.
|
|
code, err := g.readPluginSource(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"name": path, "code": code,
|
|
"enabled": g.core.Plugins().Enabled(path),
|
|
})
|
|
case http.MethodPut:
|
|
// Enable / disable. The intent rides in the body rather than the
|
|
// verb, because "enable" and "disable" are the same resource and
|
|
// PUT /{name} is the one the docs advertise.
|
|
var body struct {
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if body.Enabled == nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", `body must be {"enabled":true|false}`)
|
|
return
|
|
}
|
|
if err := g.core.Plugins().SetEnabled(path, *body.Enabled); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"ok": true, "name": path, "enabled": *body.Enabled,
|
|
})
|
|
case http.MethodDelete:
|
|
if err := g.removePlugin(path); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
return
|
|
}
|
|
|
|
writeError(w, http.StatusNotFound, "not_found", "unknown plugin sub-resource: "+path)
|
|
}
|
|
|
|
// readPluginSource returns a plugin's file contents for the editor.
|
|
func (g *Gateway) readPluginSource(name string) (string, error) {
|
|
if err := validPluginName(name); err != nil {
|
|
return "", err
|
|
}
|
|
if g.pluginDir() == "" {
|
|
return "", errNoPluginDir
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(g.pluginDir(), name+".lua"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("no plugin source named %q", name)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// validPluginName rejects anything that could escape the plugin directory.
|
|
// Shared by install / remove / read so the check cannot drift between them.
|
|
func validPluginName(name string) error {
|
|
if name == "" || strings.ContainsAny(name, `/\.`) {
|
|
return errPluginName
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handlePluginState serves GET (read state) and PUT (replace state).
|
|
func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name string) {
|
|
ps := g.core.Plugins()
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// The plugin's audience gates its DATA as well as its page. Hiding the UI
|
|
// while leaving GET /state open would put the numbers one curl away, which
|
|
// is the leak the audience field exists to close — and billing's state is
|
|
// a global ledger, not the caller's own usage.
|
|
//
|
|
// A plugin that declares no audience is unchanged (every role may read
|
|
// it), so a single-purpose widget keeps working without edits.
|
|
role := reqRole(r.Context())
|
|
if !ps.StateVisibleTo(name, role) {
|
|
writeError(w, http.StatusForbidden, "forbidden",
|
|
"plugin "+name+" is not visible to role "+role)
|
|
return
|
|
}
|
|
// prices rides along: the billing rules editor must show the table
|
|
// that is actually in effect, not a reconstruction from config. It is
|
|
// absent for plugins with no prices (every plugin but billing).
|
|
state := ps.State(name)
|
|
// A plugin may SPLIT its state: global buckets stay deployment-wide while
|
|
// per-caller buckets are narrowed to the authenticated key. Role
|
|
// visibility alone cannot express this — the same role is allowed to read
|
|
// the plugin, just not another caller's row.
|
|
if scope := ps.StateScopeFor(name); scope != nil && role != "admin" {
|
|
state = splitStateForKey(state, scope, reqKey(r.Context()))
|
|
}
|
|
payload := map[string]interface{}{
|
|
"plugin": name,
|
|
"state": state,
|
|
}
|
|
if pr := ps.Prices(name); pr != nil {
|
|
payload["prices"] = pr
|
|
}
|
|
writeJSON(w, http.StatusOK, payload)
|
|
case http.MethodPut:
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
var state interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
if err := ps.SetState(name, state); err != nil {
|
|
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
// Call invokes a named function on a plugin's Lua state and returns its result.
|
|
//
|
|
// This exists because plugin pages otherwise have NO way to reach their own
|
|
// plugin code: pluginAPI offers state read/write and a couple of callbacks, and
|
|
// that is enough for billing (whose numbers arrive on hooks during request
|
|
// forwarding) but useless for a plugin that must do work on demand. A quota
|
|
// page has to ask the vendor when the operator clicks Refresh, and no hook
|
|
// fires at that moment.
|
|
//
|
|
// It is deliberately narrow:
|
|
// - The function must be one the plugin explicitly lists in plugin.exports.
|
|
// Anything else is refused, so this endpoint cannot become a generic "run
|
|
// arbitrary Lua with my credentials" oracle over a plugin's private state.
|
|
// - The caller must be admin, and the result is JSON — no Lua value is ever
|
|
// handed back to Go as a live closure.
|
|
// - Errors come back as (nil, message) from the plugin, matching every other
|
|
// plugin entry point, and surface as a 502 rather than raising.
|
|
func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) {
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
return
|
|
}
|
|
var args map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
result, err := g.core.Plugins().Call(name, fn, args)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
|
|
}
|
|
|
|
func (g *Gateway) pluginDir() string {
|
|
if c := g.core.Config(); c != nil {
|
|
return c.PluginDir
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// installPlugin writes a plugin to disk and hot-loads it. A syntax error is
|
|
// returned to the caller AND the file is left on disk so the operator can fix
|
|
// it, matching how adapters behave (the file is authoritative once present).
|
|
func (g *Gateway) installPlugin(name, code string) error {
|
|
if err := validPluginName(name); err != nil {
|
|
return err
|
|
}
|
|
dir := g.pluginDir()
|
|
if dir == "" {
|
|
return errNoPluginDir
|
|
}
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(code), 0644); err != nil {
|
|
return err
|
|
}
|
|
return g.core.Plugins().LoadSource(name, code)
|
|
}
|
|
|
|
func (g *Gateway) removePlugin(name string) error {
|
|
if err := validPluginName(name); err != nil {
|
|
return err
|
|
}
|
|
if g.pluginDir() == "" {
|
|
return errNoPluginDir
|
|
}
|
|
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
|
|
return g.core.Plugins().Unload(name)
|
|
}
|
|
|
|
|
|
// handleEndpointAccess serves the operator's view and edit surface for one
|
|
// plugin endpoint's reachable keys.
|
|
//
|
|
// GET …/http/{path}/access the declared rules plus any override
|
|
// PUT …/http/{path}/access body {"access": "admin,@homeagent"}
|
|
// DELETE …/http/{path}/access clear the override
|
|
//
|
|
// The spec string is a comma list: a bare word is a role, "@name" is a gateway
|
|
// key's configured name, and the literal "public" opens it to any
|
|
// authenticated key. That is the same grammar the visibility override uses, and
|
|
// keeping one grammar means an operator learns it once.
|
|
//
|
|
// Key names are validated against the configured keys BEFORE being stored. An
|
|
// allowlist naming a key that does not exist is a silent no-op — every caller
|
|
// is refused — and the operator would have no way to tell that apart from the
|
|
// endpoint simply working.
|
|
func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) {
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
ps := g.core.Plugins()
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
for _, e := range ps.EndpointAccess() {
|
|
if e.Plugin == name && e.Path == epPath {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e})
|
|
return
|
|
}
|
|
}
|
|
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
|
|
case http.MethodPut, http.MethodDelete:
|
|
spec := ""
|
|
if r.Method == http.MethodPut {
|
|
var body struct {
|
|
Access string `json:"access"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
|
return
|
|
}
|
|
spec = body.Access
|
|
}
|
|
if err := ps.ValidateAccessSpec(spec); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
if err := ps.SetEndpointAccess(name, epPath, spec); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
|
|
}
|
|
}
|
|
|
|
|
|
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
|
|
// the buckets that belong to them.
|
|
//
|
|
// The plugin declares which buckets are global and which are per-key. A per-key
|
|
// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log
|
|
// uses), so the caller's own row is found by masking the presented secret and
|
|
// matching the last six characters.
|
|
//
|
|
// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as
|
|
// "you spent nothing" and, worse, would leak that a row exists for someone else.
|
|
func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} {
|
|
m, ok := state.(map[string]interface{})
|
|
if !ok || scope == nil || key == "" {
|
|
return state
|
|
}
|
|
masked := maskKey(key)
|
|
out := make(map[string]interface{}, len(m))
|
|
for k, v := range m {
|
|
if !scope.IsGlobal(k) {
|
|
// Per-key bucket: keep only the caller's rows, at every nesting level
|
|
// the scope mentions (by_day_key is {day: {masked: bucket}}).
|
|
out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k))
|
|
continue
|
|
}
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// filterKeyBuckets narrows a keyed structure to one masked key.
|
|
//
|
|
// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is
|
|
// declared rather than sniffed, because both shapes are dicts and sniffing would
|
|
// confuse an empty bucket with a nested one.
|
|
func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
|
|
switch depth {
|
|
case 0:
|
|
return v // not a per-key bucket; leave alone
|
|
case 1:
|
|
m, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
return v
|
|
}
|
|
if row, ok := m[masked]; ok {
|
|
return map[string]interface{}{masked: row}
|
|
}
|
|
return map[string]interface{}{}
|
|
default:
|
|
m, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
return v
|
|
}
|
|
out := make(map[string]interface{}, len(m))
|
|
for day, inner := range m {
|
|
im, ok := inner.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if row, ok := im[masked]; ok {
|
|
out[day] = map[string]interface{}{masked: row}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
}
|