package main import ( "net/http" "sort" ) // schema.go —— 可配置项总目录(给 agent 用) // // 目的:让 agent/脚本不必猜就有据可依——把所有可配置项连同**怎么改**(方法、路径、 // 请求体字段)一次性暴露出来。人用后台界面,agent 用这里的接口,改的是同一份数据。 // // GET /api/schema → {"note":…,"items":[…],"resources":[…]} // // items : 单值配置项(站点信息、认证页/门户页文案、备案号…),读用 GET、写用 POST。 // resources : 结构化资源(导航分组与条目、服务转发路由表、门户卡片、壁纸、门户口令、历史…)。 // pageInfoFields 描述 conf.yml → pageInfo 下所有可配置文案字段。 // 列全"即使当前为空"的字段,agent 才能发现它们的存在。 var pageInfoFields = []struct{ Key, Label, Hint string }{ {"title", "站点标题", "浏览器标题与导航页标题"}, {"description", "站点描述", "导航页副标题/搜索页描述"}, {"authTitle", "登录页标题", "留空回落内置默认"}, {"authSubtitle", "登录页副标题", "留空回落内置默认"}, {"authLogo", "登录页徽标", "短文字,如 JF"}, {"portalTitle", "门户页浏览器标题", "留空回落内置默认"}, {"heroTitle", "门户页主标题", "留空回落内置默认"}, {"heroSubtitle", "门户页副标题", "留空回落内置默认"}, {"icp", "ICP 备案号", "显示于各页页脚;被代理页面也会注入"}, {"gongan", "公安备案号", "可选,显示于页脚"}, {"motto", "页脚座右铭", "每行一句(换行或 | 分隔);留空用内置默认"}, {"footer", "自定义页脚 HTML", "高级:整段替换页脚内容(登录页/门户页同样生效)"}, {"navLinks", "页脚导航链接数组", "数组,元素形如 {name,url}"}, } type schemaItem struct { Key string `json:"key"` Label string `json:"label"` Type string `json:"type"` Value any `json:"value"` Hint string `json:"hint,omitempty"` Write struct { Method string `json:"method"` Path string `json:"path"` Body any `json:"bodyExample"` } `json:"write"` } type schemaResource struct { ID string `json:"id"` Label string `json:"label"` Read struct { Method string `json:"method"` Path string `json:"path"` } `json:"read"` Write struct { Method string `json:"method"` Path string `json:"path"` Body any `json:"bodyExample,omitempty"` } `json:"write"` Hint string `json:"hint,omitempty"` } func newSchemaItem(key, label, typ string, value any, hint, path string, body any) schemaItem { it := schemaItem{Key: key, Label: label, Type: typ, Value: value, Hint: hint} it.Write.Method = http.MethodPost it.Write.Path = path it.Write.Body = body return it } // handleSchema 返回全部可配置项及其写入口,供 agent 直接按图配置。 func handleSchema(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonErr(w, 405, "只支持 GET") return } info := map[string]any{} if cfg, err := loadConfig(); err == nil && cfg != nil && cfg.PageInfo != nil { info = cfg.PageInfo } items := make([]schemaItem, 0, len(pageInfoFields)) for _, f := range pageInfoFields { typ := "string" if f.Key == "navLinks" { typ = "array" } items = append(items, newSchemaItem( "pageInfo."+f.Key, f.Label, typ, info[f.Key], f.Hint, "/api/pageinfo", map[string]any{f.Key: "<新值>"}, )) } // 实际存在于配置但未在 catalog 里登记的键也一并暴露(避免"改了看不到") var extra []string for k := range info { known := false for _, f := range pageInfoFields { if f.Key == k { known = true break } } if !known { extra = append(extra, k) } } sort.Strings(extra) for _, k := range extra { items = append(items, newSchemaItem("pageInfo."+k, "自定义键 "+k, "any", info[k], "配置里已存在的额外键", "/api/pageinfo", map[string]any{k: "<新值>"})) } res := func(id, label, readPath, writePath string, body any, hint string) schemaResource { var sr schemaResource sr.ID, sr.Label, sr.Hint = id, label, hint sr.Read.Method, sr.Read.Path = http.MethodGet, readPath sr.Write.Method, sr.Write.Path = http.MethodPost, writePath sr.Write.Body = body return sr } resources := []schemaResource{ res("navSections", "导航分组与条目", "/api/sections", "/api/item", map[string]any{"name": "服务名", "url": "https://…", "section": "常用服务", "icon": "auto", "description": "说明"}, "分组:/api/section(POST 新建、PUT 改名)、/api/section/update、DELETE /api/section;条目:/api/item(POST 新建)、/api/item/delete"), res("gatewayRoutes", "服务转发路由表", "/api/gateway/routes", "/api/gateway/routes", map[string]any{"host": "app.example.com", "upstream": "http://127.0.0.1:3000", "requireLogin": true, "websocket": false, "maxBodyMB": 64, "kind": "proxy", "navEnabled": true, "navSection": "常用服务", "navName": "我的应用"}, "写完后必须 POST /api/gateway/apply 才会生效(内部会 nginx -t 校验,失败保留旧配置)。导入现有 nginx 配置:POST /api/gateway/import"), res("portalCards", "门户页服务卡片 HTML", "/api/portal-cards", "/api/portal-cards", map[string]any{"html": "示例"}, "替换门户页 之间的内容;空串=恢复内置卡片。整页替换可写数据卷 portal/index.html"), res("wallpaper", "导航页壁纸", "/api/wallpaper", "/api/wallpaper", map[string]any{"url": "https://…/wall.jpg"}, "置空字符串表示移除壁纸;深浅色模式下都会保留壁纸"), res("portalAuth", "统一登录账号", "/api/portal-auth", "/api/portal-auth", map[string]any{"username": "admin", "password": "<新口令>"}, "口令以加盐哈希存数据卷,改完旧会话失效"), res("history", "浏览器历史记录", "/api/history", "/api/history/item", nil, "删除单条:DELETE /api/history/item?url=…;清空:DELETE /api/history;同步接口 /api/history/sync 用 NAVI_SYNC_TOKEN"), res("reload", "重载导航配置", "/api/sync-info", "/api/reload", nil, "改完 conf.yml 或需要重建导航分区时调用"), res("icon", "上传图标", "/api/schema", "/api/icon", nil, "multipart/form-data,字段名 file;支持 png/svg/jpg/ico"), } jsonResp(w, map[string]any{ "note": "所有可配置项集中于此。读用 GET;写用 POST,需 Authorization: Bearer (后台页面已登录则可直接用界面)。结构化资源的写入口见 resources。", "items": items, "resources": resources, }) }