package main import ( "bytes" "encoding/json" "fmt" "html" "io" "log" "net/http" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "time" "gopkg.in/yaml.v3" ) var ( confFile = envOr("DASHY_CONF", "/app/data/conf.yml") adminToken = os.Getenv("NAVI_ADMIN_TOKEN") // requireTokenForWrite:是否需要额外的 Bearer token。 // // /admin/ 在生产里整体位于外层统一认证反向代理(portal-nginx 的 auth_request) // 之后,再叠一层 token 是重复的,而且会让 admin UI 的写操作静默 401。 // 所以默认**不校验**;只有当你把容器端口直接暴露给不受信的网段、 // 而没有外层认证时才设 NAVI_ADMIN_REQUIRE_TOKEN=1。 requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN")) // 浏览器插件同步用的令牌(供安装引导页展示,方便用户拷贝) syncToken = os.Getenv("NAVI_SYNC_TOKEN") // history-server 地址(管理后台转发历史记录管理请求用) historyBaseURL = envOr("NAVI_HISTORY_URL", "http://127.0.0.1:3004") // 本地上传的图标目录(Dashy 通过 /item-icons/ 读取) iconDir = envOr("NAVI_ICON_DIR", filepath.Join(filepath.Dir(confFile), "item-icons")) // 壁纸目录(nginx 通过 /wallpapers/ 提供) wallpaperDir = envOr("NAVI_WALLPAPER_DIR", filepath.Join(filepath.Dir(confFile), "wallpapers")) mu sync.Mutex ) // ── 壁纸 ── // // Dashy 没有原生壁纸配置,所以由服务端把一段带标记的 CSS 写进 // appConfig.customCss。标记块可反复替换,不碰主题的其余部分。 const ( wallpaperBegin = "/* ==NAVI-WALLPAPER-BEGIN== */" wallpaperEnd = "/* ==NAVI-WALLPAPER-END== */" wallpaperMaxBytes = 8 << 20 ) // applyWallpaperBlock 把壁纸规则替换/插入 customCss;url 为空则只移除。 // dim 是铺在图片上的白色遮罩强度,保证玻璃卡片上的文字仍然可读。 func applyWallpaperBlock(css, url string, dim float64) string { for { i := strings.Index(css, wallpaperBegin) if i < 0 { break } rest := css[i:] j := strings.Index(rest, wallpaperEnd) if j < 0 { css = css[:i] break } css = css[:i] + rest[j+len(wallpaperEnd):] } css = strings.TrimRight(css, " \t\r\n") if url == "" { return css + "\n" } if dim < 0 { dim = 0 } if dim > 0.9 { dim = 0.9 } scrim := strconv.FormatFloat(dim, 'f', 2, 64) return css + "\n\n" + wallpaperBegin + "\n" + "html[data-theme] body{\n" + " background-image:\n" + " linear-gradient(rgba(255,255,255," + scrim + "), rgba(255,255,255," + scrim + ")),\n" + " url(\"" + url + "\") !important;\n" + " background-size: cover !important;\n" + " background-position: center center !important;\n" + " background-attachment: fixed !important;\n" + " background-repeat: no-repeat !important;\n" + "}\n" + wallpaperEnd + "\n" } // safeCSSURL 避免把引号/括号/换行注入到 CSS 里。 func safeCSSURL(u string) bool { if u == "" { return true } if len(u) > 512 { return false } if strings.ContainsAny(u, "\"'()\\\n\r\t") { return false } return strings.HasPrefix(u, "/") || strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") } func readWallpaper(cfg *Config) map[string]any { url, _ := cfg.AppConfig["wallpaper"].(string) dim := 0.45 switch v := cfg.AppConfig["wallpaperDim"].(type) { case float64: dim = v case int: dim = float64(v) } return map[string]any{"url": url, "dim": dim} } // handleWallpaper 支持: // // GET 查看当前壁纸 // POST multipart 上传图片(字段 file) // POST json 直接设置 URL 或关闭({"url":"","dim":0.45}) func handleWallpaper(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } if cfg.AppConfig == nil { cfg.AppConfig = map[string]any{} } switch r.Method { case http.MethodGet: jsonResp(w, readWallpaper(cfg)) return case http.MethodPost: default: jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } url := "" dim := 0.45 if v, ok := cfg.AppConfig["wallpaperDim"].(float64); ok { dim = v } ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "multipart/form-data") { r.Body = http.MaxBytesReader(w, r.Body, wallpaperMaxBytes+1<<20) if err := r.ParseMultipartForm(4 << 20); err != nil { jsonErr(w, 400, "解析上传失败: "+err.Error()) return } file, _, err := r.FormFile("file") if err != nil { jsonErr(w, 400, "缺少 file 字段") return } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, wallpaperMaxBytes+1)) if err != nil { jsonErr(w, 400, err.Error()) return } if len(data) > wallpaperMaxBytes { jsonErr(w, 400, "壁纸过大(上限 8MB)") return } ext, ok := detectWallpaperExt(data) if !ok { jsonErr(w, 400, "壁纸只支持 png / jpg / webp / gif / svg") return } name := fmt.Sprintf("wall-%d%s", time.Now().Unix(), ext) if err := os.MkdirAll(wallpaperDir, 0755); err != nil { jsonErr(w, 500, err.Error()) return } if err := os.WriteFile(filepath.Join(wallpaperDir, name), data, 0644); err != nil { jsonErr(w, 500, err.Error()) return } url = "/wallpapers/" + name } else { var body struct { URL *string `json:"url"` Dim *float64 `json:"dim"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonErr(w, 400, err.Error()) return } if body.URL != nil { url = strings.TrimSpace(*body.URL) } else { url, _ = cfg.AppConfig["wallpaper"].(string) } if body.Dim != nil { dim = *body.Dim } } if !safeCSSURL(url) { jsonErr(w, 400, "壁纸地址不合法(需以 / 或 http(s):// 开头,且不含引号括号)") return } css, _ := cfg.AppConfig["customCss"].(string) cfg.AppConfig["customCss"] = applyWallpaperBlock(css, url, dim) cfg.AppConfig["wallpaper"] = url cfg.AppConfig["wallpaperDim"] = dim if err := saveConfig(cfg); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, map[string]any{"url": url, "dim": dim}) } // detectWallpaperExt 以正文特征判定图片类型。 func detectWallpaperExt(data []byte) (string, bool) { switch { case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")): return ".png", true case bytes.HasPrefix(data, []byte("\xff\xd8\xff")): return ".jpg", true case bytes.HasPrefix(data, []byte("GIF87a")), bytes.HasPrefix(data, []byte("GIF89a")): return ".gif", true case len(data) >= 12 && bytes.HasPrefix(data, []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")): return ".webp", true } trim := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})) low := bytes.ToLower(trim) if len(low) > 512 { low = low[:512] } if bytes.HasPrefix(low, []byte(" 299 { return frameVerdict{Frameable: true, Conclusive: false, Reason: fmt.Sprintf("探测返回 HTTP %d", res.StatusCode)} } return frameVerdict{Frameable: true, Conclusive: true} } func handleFrameable(w http.ResponseWriter, r *http.Request) { target := r.URL.Query().Get("url") if target == "" { jsonErr(w, 400, "缺少 url 参数") return } jsonResp(w, frameableVerdict(target, r.Header.Get("Cookie"))) } func handleSections(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, cfg.Sections) } // ── 分组(section)增 / 改 / 删 ── // // 「历史访问」分组由 history-server 依据浏览记录重建,改名或删除都会被它 // 覆写回来;这里明确拒绝,避免用户以为删掉了、下次同步又“长”回来。 const historySectionName = "历史访问" func isHistorySection(s Section) bool { return strings.TrimSpace(s.Name) == historySectionName } func handleAddSection(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } mu.Lock() defer mu.Unlock() var req struct { Name string `json:"name"` Icon string `json:"icon"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "请求体解析失败") return } name := strings.TrimSpace(req.Name) if name == "" { jsonErr(w, 400, "分组名不能为空") return } if name == historySectionName { jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能手工创建") return } cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } for _, s := range cfg.Sections { if strings.TrimSpace(s.Name) == name { jsonErr(w, 409, "已存在同名分组") return } } icon := strings.TrimSpace(req.Icon) if icon == "" { icon = "fas fa-star" } cfg.Sections = append(cfg.Sections, Section{Name: name, Icon: icon}) if err := saveConfig(cfg); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, cfg.Sections) } func handleUpdateSection(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } mu.Lock() defer mu.Unlock() var req struct { Index int `json:"index"` Name string `json:"name"` Icon string `json:"icon"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "请求体解析失败") return } cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } if req.Index < 0 || req.Index >= len(cfg.Sections) { jsonErr(w, 400, "分组不存在") return } if isHistorySection(cfg.Sections[req.Index]) { jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能修改") return } name := strings.TrimSpace(req.Name) if name == "" { jsonErr(w, 400, "分组名不能为空") return } if name == historySectionName { jsonErr(w, 409, "不能使用分组名「"+historySectionName+"」(它由浏览历史自动维护)") return } for i, s := range cfg.Sections { if i != req.Index && strings.TrimSpace(s.Name) == name { jsonErr(w, 409, "已存在同名分组") return } } cfg.Sections[req.Index].Name = name if icon := strings.TrimSpace(req.Icon); icon != "" { cfg.Sections[req.Index].Icon = icon } if err := saveConfig(cfg); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, cfg.Sections) } // handleDeleteSection 删除分组。默认拒绝删除非空分组(需 force=1), // 避免误点一下就把一组服务连带删掉。 func handleDeleteSection(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } mu.Lock() defer mu.Unlock() var req struct { Index int `json:"index"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "请求体解析失败") return } cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } if req.Index < 0 || req.Index >= len(cfg.Sections) { jsonErr(w, 400, "分组不存在") return } if isHistorySection(cfg.Sections[req.Index]) { jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能删除") return } if n := len(cfg.Sections[req.Index].Items); n > 0 && r.URL.Query().Get("force") != "1" { jsonErr(w, 409, fmt.Sprintf("该分组下还有 %d 个服务,删除会一并移除;确认请加 force=1", n)) return } cfg.Sections = append(cfg.Sections[:req.Index], cfg.Sections[req.Index+1:]...) if err := saveConfig(cfg); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, cfg.Sections) } func handleAddItem(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } mu.Lock() defer mu.Unlock() var req struct { SectionIndex int `json:"sectionIndex"` Item NavItem `json:"item"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, err.Error()) return } if req.Item.Title == "" || req.Item.URL == "" { jsonErr(w, 400, "title and url required") return } // Block javascript: URLs if strings.HasPrefix(strings.ToLower(req.Item.URL), "javascript:") { jsonErr(w, 400, "javascript: URLs not allowed") return } cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) { jsonErr(w, 400, "invalid section index") return } cfg.Sections[req.SectionIndex].Items = append(cfg.Sections[req.SectionIndex].Items, req.Item) if err := saveConfig(cfg); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, map[string]string{"status": "ok"}) } func handleDeleteItem(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } mu.Lock() defer mu.Unlock() var req struct { SectionIndex int `json:"sectionIndex"` ItemIndex int `json:"itemIndex"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, err.Error()) return } cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) { jsonErr(w, 400, "invalid section index") return } sec := &cfg.Sections[req.SectionIndex] if req.ItemIndex < 0 || req.ItemIndex >= len(sec.Items) { jsonErr(w, 400, "invalid item index") return } sec.Items = append(sec.Items[:req.ItemIndex], sec.Items[req.ItemIndex+1:]...) if err := saveConfig(cfg); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, map[string]string{"status": "ok"}) } // handleSyncInfo 把浏览器插件需要的接入信息给已登录用户。 // // 为何可以在这里返回令牌:/admin/ 整体在门户登录墙之内; // 而 /api/history/sync 是被门户放行的(否则插件无法上报), // 所以这个 token 仍然是有意义的凭据,并且不能挂在 /api/ 下暴露。 func handleSyncInfo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonErr(w, 405, "method not allowed") return } jsonResp(w, map[string]any{ "syncToken": syncToken, "hasToken": syncToken != "", "requireAdminToken": requireTokenForWrite, }) } // beianFooter 按固定模板生成页脚备案号 HTML。 // // 备案号在 /admin/ 里就是两个普通输入框,模板统一由服务端生成, // 用户不必写 HTML;类名与 web/navi.css 里的 .navi-footer 对应。 // footer 字段本身仍然保留:传了自定义 HTML 就以它为准(高级用法)。 func beianFooter(icp, gongan string) string { icp = strings.TrimSpace(icp) gongan = strings.TrimSpace(gongan) parts := make([]string, 0, 2) if icp != "" { parts = append(parts, ``+html.EscapeString(icp)+``) } if gongan != "" { parts = append(parts, ``+html.EscapeString(gongan)+``) } if len(parts) == 0 { return "" } return `` } // mapString 读取 map 里的字符串字段,缺失或类型不符时返回空串。 func mapString(m map[string]any, key string) string { v, _ := m[key].(string) return v } // ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ── // 这些东西原先只能在 conf.yml 里手改,现在可在 /admin/ 编辑。 func handlePageInfo(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() cfg, err := loadConfig() if err != nil { jsonErr(w, 500, err.Error()) return } if cfg.PageInfo == nil { cfg.PageInfo = map[string]any{} } switch r.Method { case http.MethodGet: jsonResp(w, cfg.PageInfo) case http.MethodPost: if !requireAdminToken(w, r) { return } var body struct { Title *string `json:"title"` Description *string `json:"description"` Footer *string `json:"footer"` ICP *string `json:"icp"` Gongan *string `json:"gongan"` NavLinks *[]map[string]any `json:"navLinks"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonErr(w, 400, err.Error()) return } if body.Title != nil { cfg.PageInfo["title"] = *body.Title } if body.Description != nil { cfg.PageInfo["description"] = *body.Description } if body.Footer != nil { cfg.PageInfo["footer"] = *body.Footer } // 备案号单独存字段,便于在 /admin/ 里直接编辑,而不是让用户写 HTML。 if body.ICP != nil { cfg.PageInfo["icp"] = *body.ICP } if body.Gongan != nil { cfg.PageInfo["gongan"] = *body.Gongan } // 页脚为空(留空 = 交给模板)时,按备案号重新生成。 if strings.TrimSpace(mapString(cfg.PageInfo, "footer")) == "" { cfg.PageInfo["footer"] = beianFooter(mapString(cfg.PageInfo, "icp"), mapString(cfg.PageInfo, "gongan")) } if body.NavLinks != nil { cfg.PageInfo["navLinks"] = *body.NavLinks } if err := saveConfig(cfg); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, cfg.PageInfo) default: jsonErr(w, 405, "method not allowed") } } // ── 图标上传 ── // // 只接受 Dashy 能渲染的格式:其 isImage() 白名单是 [svg,png,jpg]。 // .ico 渲染不出来,由前端先用 canvas 转成 PNG 再上传。 const iconMaxBytes = 512 << 10 func handleIconUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } r.Body = http.MaxBytesReader(w, r.Body, 4<<20) if err := r.ParseMultipartForm(4 << 20); err != nil { jsonErr(w, 400, "解析上传失败: "+err.Error()) return } file, hdr, err := r.FormFile("file") if err != nil { jsonErr(w, 400, "缺少 file 字段") return } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, iconMaxBytes+1)) if err != nil { jsonErr(w, 400, err.Error()) return } if len(data) > iconMaxBytes { jsonErr(w, 400, "图标过大(上限 512KB)") return } ext, ok := detectIconExt(data) if !ok { jsonErr(w, 400, "只支持 png / svg / jpg(.ico 请在页面上选择,会自动转成 png)") return } name := safeIconName(r.FormValue("name"), hdr.Filename, ext) if err := os.MkdirAll(iconDir, 0755); err != nil { jsonErr(w, 500, err.Error()) return } if err := os.WriteFile(filepath.Join(iconDir, name), data, 0644); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, map[string]string{"icon": name}) } // detectIconExt 以**正文特征**判定类型,不信任上传的文件名/Cotent-Type。 func detectIconExt(data []byte) (string, bool) { switch { case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")): return ".png", true case bytes.HasPrefix(data, []byte("\xff\xd8\xff")): return ".jpg", true } trim := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})) low := bytes.ToLower(trim) if len(low) > 512 { low = low[:512] } if bytes.HasPrefix(low, []byte("= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': b.WriteRune(r) case r == '.' || r == ' ': b.WriteByte('-') } } s := b.String() if s == "" { s = "icon" } if len(s) > 48 { s = s[:48] } return fmt.Sprintf("%s-%d%s", s, time.Now().Unix(), ext) } // firstLines 取前 n 行,避免把 nginx 的长输出整段塞进界面。 func firstLines(s string, n int) string { lines := strings.Split(strings.TrimSpace(s), "\n") if len(lines) > n { lines = lines[:n] } return strings.Join(lines, "\n") } // ── 统一入口(gateway)路由表 API ── // // GET /api/gateway/routes → 当前路由表(含"哪些域名走统一认证") // POST /api/gateway/routes → 整体替换并保存(不立即生效) // POST /api/gateway/apply → 生成配置 → nginx -t 校验 → 通过才 reload // // 校验失败时保留上一份可用配置,并把 nginx 的原始报错回给界面, // 避免"点了应用,整个入口挂了"。 func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: cfg, err := loadGateway() if err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, cfg) case http.MethodPost: if !requireAdminToken(w, r) { return } mu.Lock() defer mu.Unlock() var req GatewayConfig if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, 400, "请求体解析失败") return } if req.Routes == nil { req.Routes = []Route{} } for _, rt := range req.Routes { if err := ValidateRoute(rt); err != nil { jsonErr(w, 400, err.Error()) return } } if _, err := RenderGatewayConf(&req); err != nil { jsonErr(w, 400, err.Error()) return } if req.ListenPort == 0 { req.ListenPort = 8081 } if req.ListenTLSPort == 0 { req.ListenTLSPort = 8443 } if err := saveGateway(&req); err != nil { jsonErr(w, 500, err.Error()) return } jsonResp(w, map[string]string{"status": "saved"}) default: jsonErr(w, 405, "method not allowed") } } func nginxCommand(args ...string) (string, error) { cmd := exec.Command("nginx", args...) out, err := cmd.CombinedOutput() return string(out), err } func handleGatewayApply(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") return } if !requireAdminToken(w, r) { return } mu.Lock() defer mu.Unlock() cfg, err := loadGateway() if err != nil { jsonErr(w, 500, err.Error()) return } conf, err := RenderGatewayConf(cfg) if err != nil { jsonErr(w, 400, err.Error()) return } if err := os.MkdirAll(gatewayOutDir, 0o755); err != nil { jsonErr(w, 500, err.Error()) return } target := gatewayFileFor() prev, hadPrev := []byte{}, false if b, err := os.ReadFile(target); err == nil { prev, hadPrev = b, true } if err := os.WriteFile(target, []byte(conf), 0o644); err != nil { jsonErr(w, 500, err.Error()) return } // 校验:不通过就把上一份写回去,保证入口始终有一份可用配置 if out, err := nginxCommand("-t"); err != nil { if hadPrev { _ = os.WriteFile(target, prev, 0o644) } else { _ = os.Remove(target) } jsonErr(w, 400, "配置未通过 nginx 校验,已保留上一份可用配置:\n"+firstLines(out, 12)) return } if out, err := nginxCommand("-s", "reload"); err != nil { jsonErr(w, 500, "reload 失败:"+firstLines(out, 6)) return } jsonResp(w, map[string]any{"status": "applied", "routes": len(cfg.Routes), "file": target}) } func main() { // Ensure conf file exists if _, err := os.Stat(confFile); os.IsNotExist(err) { defaultConf := envOr("NAVI_DEFAULT_CONFIG", "/app/defaults/conf.yml") if data, err := os.ReadFile(defaultConf); err == nil { os.MkdirAll(filepath.Dir(confFile), 0755) os.WriteFile(confFile, data, 0644) } } // Serve admin UI static files (nginx strips /admin/ prefix) http.Handle("/", http.FileServer(http.Dir("/app/admin"))) http.HandleFunc("/api/sections", cors(handleSections)) http.HandleFunc("/api/item", cors(handleAddItem)) http.HandleFunc("/api/item/delete", cors(handleDeleteItem)) http.HandleFunc("/api/section", cors(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: handleAddSection(w, r) case http.MethodDelete: handleDeleteSection(w, r) default: jsonErr(w, 405, "method not allowed") } })) http.HandleFunc("/api/section/update", cors(handleUpdateSection)) http.HandleFunc("/api/pageinfo", cors(handlePageInfo)) http.HandleFunc("/api/icon", cors(handleIconUpload)) http.HandleFunc("/api/wallpaper", cors(handleWallpaper)) http.HandleFunc("/api/sync-info", cors(handleSyncInfo)) http.HandleFunc("/api/frameable", cors(handleFrameable)) http.HandleFunc("/api/gateway/routes", cors(handleGatewayRoutes)) http.HandleFunc("/api/gateway/apply", cors(handleGatewayApply)) // 历史记录管理:列表 / 清空全部 / 删除单条 http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: handleHistoryList(w, r) case http.MethodDelete: handleHistoryClear(w, r) default: jsonErr(w, 405, "method not allowed") } })) http.HandleFunc("/api/history/item", cors(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { jsonErr(w, 405, "method not allowed") return } handleHistoryDeleteItem(w, r) })) http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) { jsonResp(w, map[string]string{"status": "saved"}) })) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { jsonResp(w, map[string]string{"status": "ok"}) }) log.Println("admin-server listening on :3005") log.Fatal(http.ListenAndServe(":3005", nil)) }