package webui import ( "bufio" "embed" "fmt" "log" "net" "strconv" "strings" "sync" "time" "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "errors" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" "net/http" ) // 本文件是 WebUI 的骨架:嵌入式前端资源、Handler 结构、构造、路由表、 // 鉴权/会话/日志中间件与静态页。各资源的具体 handler 见同包 handler_*.go。 //go:embed dashboard.html dashboard.css dashboard.js mascot.webp logo.svg var dashboardFS embed.FS var dashboardHTML string const loginHTML = `HomeAgent 登录

HomeAgent

智能家居助手控制台

HomeAgent · NapCat Theme
` // dashboardHTML 是组装好的控制台页面:dashboard.html 外壳 + dashboard.css + dashboard.js。 // // 前端刻意没有构建链,所以拆分的办法是:外壳里留 {{DASHBOARD_CSS}} / {{DASHBOARD_JS}} // 两个占位符,启动时把两个资产原样填回去——**发出的 HTML 与拆分前逐字节一致**, // 但 6637 行的单文件变成「外壳 + 样式 + 脚本」三份,便于编辑与评审。 func init() { html, err := dashboardFS.ReadFile("dashboard.html") if err != nil { return } out := string(html) for _, a := range []struct{ placeholder, file string }{ {"{{DASHBOARD_CSS}}", "dashboard.css"}, {"{{DASHBOARD_JS}}", "dashboard.js"}, } { body, err := dashboardFS.ReadFile(a.file) if err != nil { log.Printf("[webui] 读取前端资产 %s 失败: %v", a.file, err) continue } // 资产文件末尾的换行由占位符所在行自己的换行承担,避免多出空行。 out = strings.Replace(out, a.placeholder, strings.TrimRight(string(body), "\n"), 1) } dashboardHTML = out } type Handler struct { sdk *sdk.PluginSDK supervisor sdk.SupervisorAPI memory sdk.MemoryAPI indexer sdk.IndexerAPI adapter sdk.AdapterAPI config sdk.ConfigAPI startTime time.Time textMem sdk.TextMemoryAPI knowledge sdk.KnowledgeAPI tracker sdk.TrackerAPI settings sdk.SettingsAPI pluginMgr sdk.PluginManager status sdk.StatusAPI term sdk.TerminalAPI llm sdk.LLMAPI // hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。 hostPort string // mux 由 RegisterRoutes 记下,供 Handler() 组装生产链。 mux *http.ServeMux sessionMu sync.Mutex sessions map[string]time.Time // loginLimiter 是登录入口的按来源失败计数(见 login_limiter.go)。 // 门户可被穿透到公网,登录是唯一的口令入口,必须有滥用防护。 loginLimiter *loginLimiter // trustedProxies 是受信反代网段(CIDR 或裸 IP)。 // 只有来自这些网段的请求,其 X-Forwarded-For 才会被采信用于限流计数。 // 为空 = 不采信任何 XFF(保守默认,见 login_limiter.go 的 sourceKey)。 // 由设置项 webui.trusted_proxies 配置(逗号分隔)。 trustedProxies []string sseEvents *sseEventRing // SSE 事件环状缓冲区,Last-Event-ID 重放用 chatMu sync.Mutex chatHistory []ChatMsg pendingIdx int // chatHistory 中正在进行的 assistant 消息索引,-1 表示无 chatSeq int64 // 已分配的最大序号;单调递增,作增量查询游标 // history 是聊天记录的独立存储(默认 /webui_chat_history.json, // 插件设置 history_file 可改)。 history *historyStore // 聊天记录写盘节流:chatDirty 由 persistChatLocked 置位, // chatPersistLoop 合并连续变更后落盘(chatSaveThrottle 去抖 + // chatSaveMaxDelay 兜底),Close 前强制落最后一次。 chatDirty bool chatDirtySince time.Time chatSaveWake chan struct{} chatStop chan struct{} chatLoopDone chan struct{} chatCloseOnce sync.Once chatMsgMu sync.Mutex chatMsgCache map[string]*chatMsgEntry // client_msg_id -> 首次处理结果 chatMsgOrder []string // FIFO 淘汰序 } func NewHandler(s *sdk.PluginSDK) *Handler { var ( sup sdk.SupervisorAPI mem sdk.MemoryAPI idx sdk.IndexerAPI ad sdk.AdapterAPI cfg sdk.ConfigAPI tm sdk.TextMemoryAPI ks sdk.KnowledgeAPI tr sdk.TrackerAPI se sdk.SettingsAPI pm sdk.PluginManager st sdk.StatusAPI term sdk.TerminalAPI llm sdk.LLMAPI ) if s != nil { sup, mem, idx = s.Supervisor(), s.Memory(), s.Indexer() ad, cfg = s.Adapter(), s.Config() tm, ks, tr = s.TextMemory(), s.Knowledge(), s.Tracker() se, pm = s.Settings(), s.PluginMgr() st, llm = s.Status(), s.LLM() term = s.Terminal() } h := &Handler{ sdk: s, supervisor: sup, memory: mem, indexer: idx, adapter: ad, config: cfg, startTime: time.Now(), textMem: tm, knowledge: ks, tracker: tr, settings: se, pluginMgr: pm, status: st, term: term, llm: llm, sessions: make(map[string]time.Time), loginLimiter: newLoginLimiter(loginMaxFails, loginWindow), pendingIdx: -1, chatMsgCache: make(map[string]*chatMsgEntry), sseEvents: newSSEEventRing(200), // 聊天记录独立存储:默认 /webui_chat_history.json, // 插件设置 history_file 可改(相对路径按 data 目录解析)。 history: newHistoryStore(resolveHistoryFile(settingString(se, "history_file"), webDataDir)), chatSaveWake: make(chan struct{}, 1), chatStop: make(chan struct{}), chatLoopDone: make(chan struct{}), } go h.chatPersistLoop() h.loadChatHistory() if s != nil { h.subscribeChatEvents() } return h } func (h *Handler) getWebUIConfig() (apiKey, username, password string, ttl time.Duration) { ttl = 24 * time.Hour if h.settings == nil { return } if v, _ := h.settings.Get("api_key"); v != nil { apiKey, _ = v.(string) } if v, _ := h.settings.Get("username"); v != nil { username, _ = v.(string) } if v, _ := h.settings.Get("password"); v != nil { password, _ = v.(string) } if v, _ := h.settings.Get("session_ttl_hours"); v != nil { switch n := v.(type) { case float64: if n > 0 { ttl = time.Duration(n) * time.Hour } case string: if i, err := strconv.Atoi(n); err == nil && i > 0 { ttl = time.Duration(i) * time.Hour } } } if username == "" { username = "admin" } return } func (h *Handler) createSession() (string, time.Time, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", time.Time{}, err } _, _, _, ttl := h.getWebUIConfig() expires := time.Now().Add(ttl) token := hex.EncodeToString(buf) h.sessionMu.Lock() h.sessions[token] = expires h.sessionMu.Unlock() return token, expires, nil } func (h *Handler) validSession(r *http.Request) bool { cookie, err := r.Cookie("homeagent_session") if err != nil || cookie.Value == "" { return false } h.sessionMu.Lock() defer h.sessionMu.Unlock() expires, ok := h.sessions[cookie.Value] if !ok { return false } if time.Now().After(expires) { delete(h.sessions, cookie.Value) return false } return true } func (h *Handler) validAPIKey(r *http.Request) bool { apiKey, _, _, _ := h.getWebUIConfig() if apiKey == "" { return false } got := strings.TrimSpace(r.Header.Get("X-API-Key")) if got == "" { auth := strings.TrimSpace(r.Header.Get("Authorization")) if strings.HasPrefix(auth, "Bearer ") { got = strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) } } return got != "" && got == apiKey } func (h *Handler) requireAPI(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { apiKey, _, _, _ := h.getWebUIConfig() if apiKey == "" && !h.validSession(r) { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "webui api_key not configured"}) return } if h.validAPIKey(r) || h.validSession(r) { fn(w, r) return } writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) } } func (h *Handler) requireWeb(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // API key 客户端(ArkTS/GUI 远程连接)与 cookie session 同等放行: // agent 输出的 /files/、/uploads/ 附件 URL 会被非浏览器客户端直接加载, // 它们没有也不应有 web 登录态。 if h.validAPIKey(r) { fn(w, r) return } _, username, password, _ := h.getWebUIConfig() if username == "" || password == "" { http.Error(w, "webui username/password not configured", http.StatusServiceUnavailable) return } if h.validSession(r) { fn(w, r) return } http.Redirect(w, r, "/login", http.StatusFound) } } // clientIP 提取请求的真实客户端 IP。 // 优先取 X-Forwarded-For / X-Real-IP(反向代理/frp 场景),回退 RemoteAddr。 func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // 取第一个(最接近客户端的地址) if i := strings.IndexByte(xff, ','); i >= 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } if xr := r.Header.Get("X-Real-IP"); xr != "" { return strings.TrimSpace(xr) } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } // requestLog 每次请求的日志行(含 IP、认证方式、状态码)。 // 由 logged 中间件在请求完成后调用,authType 为 caller 预先判定。 func requestLog(r *http.Request, authType, ip string, status int, dur time.Duration) { // 认证方式判定(供排查谁调用了变更接口) if authType == "" { switch { case r.Header.Get("X-API-Key") != "" || strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "): authType = "api-key" case func() bool { c, err := r.Cookie("homeagent_session"); return err == nil && c.Value != "" }(): authType = "session" default: authType = "none" } } log.Printf("[webui] %s %s from=%s auth=%s status=%d (%s)", r.Method, r.URL.Path, ip, authType, status, dur.Round(time.Millisecond)) } // logged 中间件:包装任意 handler,记录请求 IP / 方法 / 路径 / 认证方式 / 状态码。 // 置于最外层(mux 之上),覆盖所有路由(含登录页、静态资源)。 func (h *Handler) logged(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 预判认证方式(在 requireAPI/requireWeb 之前的原始请求判定) authType := "none" switch { case h.validAPIKey(r): authType = "api-key" case h.validSession(r): authType = "session" } // SSE 长连接:不阻塞在完成时记录(连接可能持续很久),启动即记一条 if strings.HasSuffix(r.URL.Path, "/chat/events") { requestLog(r, authType, clientIP(r), http.StatusOK, 0) next.ServeHTTP(w, r) return } start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(sw, r) requestLog(r, authType, clientIP(r), sw.status, time.Since(start)) }) } // statusWriter 包装 ResponseWriter 以捕获响应状态码。 // SSE 等流式写入直接透传不做缓冲。 type statusWriter struct { http.ResponseWriter status int } func (sw *statusWriter) WriteHeader(code int) { sw.status = code sw.ResponseWriter.WriteHeader(code) } func (sw *statusWriter) Write(b []byte) (int, error) { return sw.ResponseWriter.Write(b) } func (sw *statusWriter) Flush() { if f, ok := sw.ResponseWriter.(http.Flusher); ok { f.Flush() } } func (sw *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { if hj, ok := sw.ResponseWriter.(http.Hijacker); ok { return hj.Hijack() } return nil, nil, fmt.Errorf("hijack not supported") } // proxyDispatch 是**最外层**的 Host 分发中间件。 // // 为什么不放在 mux 的 "/" 兜底里(这是实测踩出来的):stdlib ServeMux 是 // 最长前缀优先,任何更具体的模式都会先命中。插件子域上的路径若与门户某条 // 路由同名(/api/v1/device/online 就是——门户为「旧路径反代」注册了 // /api/v1/device/),请求会被那条**面向门户**的路由截走:实测表现为 401, // 且响应体是门户的 {"error":"unauthorized"} 而不是上游的响应,极难排查。 // // 包在 mux 外层后,Host 判定先于任何路径匹配发生:插件子域整体交给反代, // 主门户 Host 则原样下沉给 mux 走各自路由,两边互不干扰。 // Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → 日志 → mux)。 // // 抽成一个方法而非在 plugin.go 里手写组合:测试必须能拿到与线上**逐字节 // 相同**的链,否则很容易测出错位的东西——本次就踩过:测 mux 而中间件挂在 // plugin.go,判据全绿却在真实实例上 401。共享同一条链可以结构性地避免 // 这类漂移。 func (h *Handler) Handler() http.Handler { return h.proxyDispatch(h.logged(h.mux)) } func (h *Handler) proxyDispatch(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if h.serveProxyHost(w, r) { return } next.ServeHTTP(w, r) }) } func (h *Handler) RegisterRoutes(mux *http.ServeMux) { h.mux = mux mux.HandleFunc("/login", h.handleLoginPage) mux.HandleFunc("/api/v1/login", h.handleLogin) mux.HandleFunc("/api/v1/logout", h.handleLogout) mux.HandleFunc("/api/v1/status", h.requireAPI(h.handleStatus)) mux.HandleFunc("/api/v1/agents", h.requireAPI(h.handleAgents)) mux.HandleFunc("/api/v1/agents/", h.requireAPI(h.handleAgentByID)) mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory)) mux.HandleFunc("/api/v1/memory/", h.requireAPI(h.handleMemory)) mux.HandleFunc("/api/v1/memory/graph", h.requireAPI(h.handleMemoryGraph)) mux.HandleFunc("/api/v1/memory/context", h.requireAPI(h.handleMemoryContext)) mux.HandleFunc("/api/v1/memory/tools", h.requireAPI(h.handleMemoryTools)) mux.HandleFunc("/api/v1/memory/text", h.requireAPI(h.handleTextMemory)) mux.HandleFunc("/api/v1/network", h.requireAPI(h.handleNetwork)) mux.HandleFunc("/api/v1/config", h.requireAPI(h.handleConfig)) mux.HandleFunc("/api/v1/settings", h.requireAPI(h.handleSettings)) mux.HandleFunc("/api/v1/settings/", h.requireAPI(h.handleSettings)) mux.HandleFunc("/api/v1/knowledge", h.requireAPI(h.handleKnowledge)) mux.HandleFunc("/api/v1/knowledge/", h.requireAPI(h.handleKnowledge)) mux.HandleFunc("/api/v1/adapters", h.requireAPI(h.handleAdapters)) mux.HandleFunc("/api/v1/adapters/", h.requireAPI(h.handleAdapterByID)) mux.HandleFunc("/api/v1/tracker", h.requireAPI(h.handleTracker)) mux.HandleFunc("/api/v1/tracker/", h.requireAPI(h.handleTracker)) mux.HandleFunc("/api/v1/chat", h.requireAPI(h.handleChat)) // 用户上传文件并附带消息注入 agent(multipart:file + message) mux.HandleFunc("/api/v1/chat/file", h.requireAPI(h.handleChatFile)) mux.HandleFunc("/api/v1/chat/history", h.requireAPI(h.handleChatHistory)) mux.HandleFunc("/api/v1/chat/interrupt", h.requireAPI(h.handleChatInterrupt)) mux.HandleFunc("/api/v1/chat/events", h.requireAPI(h.handleChatEvents)) mux.HandleFunc("/api/v1/terminals", h.requireAPI(h.handleTerminals)) mux.HandleFunc("/api/v1/cmd/history", h.requireAPI(h.handleCmdHistory)) mux.HandleFunc("/api/v1/kernel", h.requireAPI(h.handleKernel)) // 运行态小快照:调度器(排队/四级中断队列/中断栈)+ 驻留子 + 通道拓扑。 // 单独一条是为了让前端能秒级刷新而不必反复拉 30KB 的 /kernel。 mux.HandleFunc("/api/v1/runtime", h.requireAPI(h.handleRuntime)) mux.HandleFunc("/api/v1/persona", h.requireAPI(h.handlePersona)) mux.HandleFunc("/api/v1/plugins", h.requireAPI(h.handlePlugins)) mux.HandleFunc("/api/v1/plugins/", h.requireAPI(h.handlePluginByID)) // 设备网关的**遗留路径反代**(装置开关 device_gateway_enabled 控制)。 // // 鉴权**不再套 requireAPI**:该路由的调用方是设备与嵌入式客户端, // 它们带的是设备接入令牌(?token= / X-API-Key),不是门户凭证;套上 // requireAPI 会把它们全部挡在 401(实测:waiter 经此路径升级握手 401)。 // 上游 remotedevice 自己用 requireToken 强制校验令牌,安全性不降级。 // // 声明了 path 的服务(新机制)会在最外层 Host 分发里更早命中, // 走不到这里;这条保留是为了「只开开关、不写声明」的老部署仍可用。 mux.HandleFunc("/api/v1/device/", h.handleDeviceGatewayProxy) // agent 发送的文件下载(webui_files 中转目录;requireWeb 与 dashboard 同源同鉴权) mux.HandleFunc("/files/", h.requireWeb(h.handleFiles)) // 用户上传文件的下载(uploads 目录,同一安全模型) mux.HandleFunc("/uploads/", h.requireWeb(h.handleUploads)) mux.HandleFunc("/v1/chat/completions", h.requireAPI(h.handleOpenAICompletions)) // /v1/models:几乎每个 OpenAI 客户端(curl 脚本、LangChain、OpenAI SDK、 // IDE 插件)启动时都会先列模型来探测服务是否可用。没有它,客户端直接 // 判定「服务不可用」而连试都不试 —— 这是最容易让集成方踩空的缺口。 mux.HandleFunc("/v1/models", h.requireAPI(h.handleOpenAIModels)) // 反代服务入口清单:给前端渲染「插件 UI」选项卡。 // 走 requireAPI:清单本身含上游地址,属于管理面信息,不该匿名可读。 mux.HandleFunc("/api/v1/proxy/services", h.requireAPI(h.handleProxyServices)) mux.HandleFunc("/api/v1/proxy", h.requireAPI(h.handleProxyInfo)) // 设备网关发现:客户端(GUI/鸿蒙/waiter)据此自动链接,不再自己拼地址。 mux.HandleFunc("/api/v1/device/gateway", h.requireAPI(h.handleDeviceGatewayDiscovery)) mux.HandleFunc("/", h.requireWeb(h.handleStatic)) } func (h *Handler) handleLoginPage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if h.validSession(r) { http.Redirect(w, r, "/", http.StatusFound) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(loginHTML)) } func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // 限流必须在**校验之前**:口令错误也要计数。否则爆破请求每次都走 // 完整套校验(含配置读取),限流也就失去了保护意义。 if !h.enforceLoginRateLimit(w, r) { return } _, username, password, _ := h.getWebUIConfig() if username == "" || password == "" { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "webui username/password not configured"}) return } var body struct { Username string `json:"username"` Password string `json:"password"` } // 体积限制必须在解码**之前**生效,且不能只依赖解码器报错: // // json.Decoder 是**按需读流**的。若请求体是「超大且非法 JSON」, // 解码器会在第 0 个字节就报语法错误,**永远不会读到上限**, // 于是 MaxBytesError 根本不会出现 —— 而 8MB 数据仍已被读入缓冲。 // 那样「限体积」只对「合法到能继续解析的大 JSON」生效。 // // 所以先用 ContentLength 快速拒绝(覆盖绝大多数真实攻击:直接发 // 声明很大的 Content-Length),再用 MaxBytesReader 兜住分块传输 // 与谎报 Content-Length 的情况。 if r.ContentLength > loginBodyLimit { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"}) return } r.Body = http.MaxBytesReader(w, r.Body, loginBodyLimit) if err := json.NewDecoder(r.Body).Decode(&body); err != nil { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"}) return } // 解析到一半也可能撞上上限(合法 JSON 但超长),再兜一次。 if r.ContentLength < 0 && bodyOverLimit(r) { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "请求体过大"}) return } writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } // 两条失败路径**必须**给完全相同的状态码与报文,否则可用于枚举用户名。 // 用 constant-time 比较:== 会在第一个不同字节处短路,泄漏 // 「猜对了几位」的时序信息(远程噪声大,但攻击者可多次采样取均值)。 userOK := subtle.ConstantTimeCompare([]byte(body.Username), []byte(username)) == 1 passOK := subtle.ConstantTimeCompare([]byte(body.Password), []byte(password)) == 1 if !userOK || !passOK { h.loginLimiter.Fail(h.sourceKey(r)) writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "用户名或密码错误"}) return } // 成功即清零:惩罚只针对持续失败,手滑输错几次不该被记账。 h.loginLimiter.Reset(h.sourceKey(r)) token, expires, err := h.createSession() if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } http.SetCookie(w, &http.Cookie{Name: "homeagent_session", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Expires: expires}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if cookie, err := r.Cookie("homeagent_session"); err == nil { h.sessionMu.Lock() delete(h.sessions, cookie.Value) h.sessionMu.Unlock() http.SetCookie(w, &http.Cookie{Name: "homeagent_session", Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) handleStatic(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") w.Write([]byte(dashboardHTML)) return } if r.URL.Path == "/mascot.webp" { data, err := dashboardFS.ReadFile("mascot.webp") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/webp") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(data) return } if r.URL.Path == "/logo.svg" { data, err := dashboardFS.ReadFile("logo.svg") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(data) return } http.NotFound(w, r) } func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) json.NewEncoder(w).Encode(data) }