package gateway import ( "bytes" "encoding/json" "io/fs" "net/http" "path" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件拥有 HTTP 表面:路由表(REST /api/v1、实时 // WebSocket hub、/api/v1/admin/* 与外部挂载的 webhook 回调)以及 // 外层中间件链。路由注册按受众切分,使守护每组 // 的 RBAC 门保持可见:公开、已认证用户、 // 私有历史、管理读与仅 owner 写。 // Handler 返回完整组装的 http.Handler:路由 REST + WS + 管理的 // ServeMux,外面包上 recover、CORS 与请求日志。 func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // RBAC 中间件按角色集各构建一次,并被每条需要它的路由 // 共用(owner>admin>viewer,DevDocs/02 §九)。viewerAuth 是「每个 // 已认证角色」:它指 viewer 角色存在的那个只读监控 // 表面(模块清单、运行时指标/trace、日志尾部 // 与通知 feed)—— 见 registerAdminRoutes。 anyAuth := s.authorize() ownerAuth := s.authorize(auth.RoleOwner) writeAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin) viewerAuth := s.authorize(auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer) s.registerPublicRoutes(mux) s.registerUserRoutes(mux, anyAuth, ownerAuth, writeAuth) s.registerHistoryRoutes(mux, writeAuth) s.registerAdminRoutes(mux, ownerAuth, writeAuth, viewerAuth) s.mountWebhooks(mux) return s.withMiddleware(mux) } // registerPublicRoutes 挂载未认证表面:存活探针加上 // 凭据端点。refresh/logout 按设计是公开的 —— 非透明 refresh // token 本身就是凭据,因此访问令牌已过期的客户端仍能 // 轮换或吊销它。二者在 token 非法时都以 401 invalid_grant // 失败关闭。 func (s *Server) registerPublicRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/health", s.handleHealth) mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh) mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) } // registerUserRoutes 挂载已认证的面向用户表面。Chat 是 // 写(owner/admin);读接受任何已认证角色,因此 viewer 仍能 // 列出人格/会话。音频 STT/TTS 与图片上传仅 owner/admin(主人的 // 私密助手,带审计),它们是原生客户端用来 // 绕过 4KB WebSocket 帧上限的 REST 表面。 func (s *Server) registerUserRoutes( mux *http.ServeMux, anyAuth func(http.Handler) http.Handler, ownerAuth func(http.Handler) http.Handler, writeAuth func(http.Handler) http.Handler, ) { mux.Handle("POST /api/v1/chat", writeAuth(http.HandlerFunc(s.handleChat))) mux.Handle("GET /api/v1/sessions", anyAuth(http.HandlerFunc(s.handleListSessions))) mux.Handle("GET /api/v1/personas", anyAuth(http.HandlerFunc(s.handleListPersonas))) // 共享的客户端枚举表(Android 协作 B4,design/07 §6): // 每个客户端据此把 id 映射为标签的权威,因此来源徽标或 // 情绪标签不会逐客户端漂移。它是 // internal/contract/enums 的只读投影,不含任何敏感内容,因此任何已认证 // 角色都可读它(上面的 /sessions 同一道门)。 mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums))) mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe))) mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech))) mux.Handle("POST /api/v1/audio/voice-clone", ownerAuth(http.HandlerFunc(s.handleVoiceClone))) // 图片上传(B1):一个 multipart POST,把光栅图片存到 // 解析后的数据目录下,返回可直接用作 // chat.image 的同源 URL。上传是 owner/admin + 带审计;静态路由是 // 能力 URL(名字不可猜)因为 chat.image / 无法携带 // bearer token。二者都走同一个网关端口(单二进制红线)。 mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload))) mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile) } // registerHistoryRoutes 挂载持久化聊天历史表面(design/16) // 加上实时端点。会话/模型读与写是主人的 // 私有历史,因此仅 owner/admin(chat 用同样的角色);消息 // 内容(与 thinking)按 design/16 §6 仅 owner/admin,而上面 // GET /sessions 中的会话元数据仍可被任何已认证角色读取。 // 创建与删除子会话会改变持久化状态。 func (s *Server) registerHistoryRoutes(mux *http.ServeMux, writeAuth func(http.Handler) http.Handler) { mux.Handle("GET /api/v1/sessions/{id}/messages", writeAuth(http.HandlerFunc(s.handleListMessages))) mux.Handle("POST /api/v1/sessions", writeAuth(http.HandlerFunc(s.handleCreateSubSession))) mux.Handle("DELETE /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handleDeleteSession))) mux.Handle("PATCH /api/v1/sessions/{id}", writeAuth(http.HandlerFunc(s.handlePatchSession))) // 读游标(D4 / Android 协作 B3):推进它会为该账号 // 每一端清除该会话的未读角标,因此它和其他会话变更一样 // 是 owner/admin 历史写。它会广播一个 `session_read` 帧。 mux.Handle("POST /api/v1/sessions/{id}/read", writeAuth(http.HandlerFunc(s.handleMarkSessionRead))) // 单条消息编辑/删除(Android 协作批次 ③a,D3)。消息由 // 自身非透明 id 定位;二者都是受 owner/admin 门控的历史写。 mux.Handle("PATCH /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handlePatchMessage))) mux.Handle("DELETE /api/v1/messages/{id}", writeAuth(http.HandlerFunc(s.handleDeleteMessage))) // 实时。 mux.HandleFunc("/ws/chat", s.handleWS) } // registerAdminRoutes 挂载管理 API。三个读层加两个写 // 层,镜像角色层级(RBAC 红线): // // - viewerAuth(owner/admin/viewer):只读监控表面 —— 模块 // 清单、运行时指标/trace、后端日志尾部与 // 通知 feed。这里每个载荷都是非敏感投影,不含 // 凭据、不含其他账号的数据、不含消息体,这正是 // 让 viewer 角色成为可用的看板/日志/监控账号的原因。 // - writeAuth 读(owner/admin):viewer 不得看到的一切 —— 记忆、 // LLM provider 栈、账号/API key、后台思考监控、审计 // 链、渠道消息日志/转录、完整会话导出。 // - writeAuth 写(owner/admin):带审计的知识搜索。 // - ownerAuth(owner):带审计的策略写、每个获取凭据的 // 渠道端点(login-qr/login-status)以及每一个其他变更。 // // 管理写路由表见 registerAdminWriteRoutes。 func (s *Server) registerAdminRoutes( mux *http.ServeMux, ownerAuth func(http.Handler) http.Handler, writeAuth func(http.Handler) http.Handler, viewerAuth func(http.Handler) http.Handler, ) { // 对 viewer 开放的管理读:只读监控/清单表面。 mux.Handle("GET /api/v1/admin/personas", viewerAuth(http.HandlerFunc(s.adminListPersonas))) mux.Handle("GET /api/v1/admin/sessions", viewerAuth(http.HandlerFunc(s.adminListSessions))) mux.Handle("GET /api/v1/admin/plugins", viewerAuth(http.HandlerFunc(s.adminListPlugins))) mux.Handle("GET /api/v1/admin/plugins/installed", viewerAuth(http.HandlerFunc(s.adminListInstalledPlugins))) mux.Handle("GET /api/v1/admin/channels", viewerAuth(http.HandlerFunc(s.adminListChannels))) mux.Handle("GET /api/v1/admin/iot", viewerAuth(http.HandlerFunc(s.adminListIoT))) mux.Handle("GET /api/v1/admin/automations", viewerAuth(http.HandlerFunc(s.adminListAutomations))) // 知识库文档列表:名字/描述加上切分/嵌入 // 状态。`?query=` 搜索在旁一并提供,且保持只读。 mux.Handle("GET /api/v1/admin/rag", viewerAuth(http.HandlerFunc(s.adminListRag))) mux.Handle("GET /api/v1/admin/observability/metrics", viewerAuth(http.HandlerFunc(s.adminObservabilityMetrics))) mux.Handle("GET /api/v1/admin/observability/trace", viewerAuth(http.HandlerFunc(s.adminObservabilityTrace))) // 后端日志尾部:logger 自带有界环形缓冲,由组合根注入 // (在那之前 501)。最新优先,按 `?level=`/`?limit=` 过滤。 mux.Handle("GET /api/v1/admin/observability/logs", viewerAuth(http.HandlerFunc(s.adminObservabilityLogs))) // 统一通知 feed(design/10 §3 #16):审计链 + 后台思考推送的 // 只读投影,因此面板的通知页不需要 // 任何新的持久化模型。 mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications))) // 保留给 owner/admin 的管理读:私有内容、其他账号的 // 数据与审计链。不对 viewer 开放。 mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories))) mux.Handle("GET /api/v1/admin/channels/messages", writeAuth(http.HandlerFunc(s.handleChannelMessages))) mux.Handle("GET /api/v1/admin/channels/{platform}/messages", writeAuth(http.HandlerFunc(s.handleChannelPlatformLog))) mux.Handle("GET /api/v1/admin/llm", writeAuth(http.HandlerFunc(s.adminGetLlm))) // 模型目录候选(裁决 03.4.2):面板「添加提供商 / 添加模型」的 // baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底。 mux.Handle("GET /api/v1/admin/llm/catalog", writeAuth(http.HandlerFunc(s.adminGetLlmCatalog))) mux.Handle("GET /api/v1/admin/users", writeAuth(http.HandlerFunc(s.adminListUsers))) mux.Handle("GET /api/v1/admin/backthink", writeAuth(http.HandlerFunc(s.adminListBackthink))) mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit))) mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession))) mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag))) mux.Handle("POST /api/v1/admin/plugins/policy", ownerAuth(http.HandlerFunc(s.adminSetPolicy))) s.registerAdminWriteRoutes(mux, ownerAuth) } // registerAdminWriteRoutes 挂载管理写路由:仅 owner,带审计。每个 handler 都会 // 持久化到真实后端存储,或用明确的 "M10 / not-wired" 错误诚实作答,而不是编造成功。 func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(http.Handler) http.Handler) { mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona))) mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona))) // 主人档案(裁决 10.4.5):白名单 + 显示名/关系备注的真实读写。 // **仅 owner** —— 这份档案决定谁能进 owner 车道(design/11 §0 的身份红线), // 因此既不给 viewer 读、也不给 admin 写。 mux.Handle("GET /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminGetOwnerProfile))) mux.Handle("PUT /api/v1/admin/owner-profile", ownerAuth(http.HandlerFunc(s.adminPutOwnerProfile))) mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser))) mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey))) mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey))) mux.Handle("POST /api/v1/admin/iot/{id}/control", ownerAuth(http.HandlerFunc(s.adminIoTControl))) mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario))) mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule))) mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation))) // 定时提醒(P0-22):cron/event 之外的第三种自动化,到点把 // reminder.trigger 发布到进程内 bus。它是真实写入(进 ReminderStore // 的轮询集合),因此与其他自动化写同门同审计。 mux.Handle("POST /api/v1/admin/automations/reminders", ownerAuth(http.HandlerFunc(s.adminCreateReminder))) mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation))) mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation))) mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider))) mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm))) mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen))) mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose))) mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest))) mux.Handle("GET /api/v1/admin/channels/{platform}/login-qr", ownerAuth(http.HandlerFunc(s.adminWeixinLoginQR))) mux.Handle("GET /api/v1/admin/channels/{platform}/login-status", ownerAuth(http.HandlerFunc(s.adminWeixinLoginStatus))) mux.Handle("GET /api/v1/admin/channels/{platform}/view", ownerAuth(http.HandlerFunc(s.adminChannelPlatformView))) mux.Handle("GET /api/v1/admin/channels/{platform}/friends", ownerAuth(http.HandlerFunc(s.adminChannelPlatformFriends))) mux.Handle("POST /api/v1/admin/channels/{platform}/friend/handle", ownerAuth(http.HandlerFunc(s.adminChannelFriendHandle))) mux.Handle("POST /api/v1/admin/channels/{platform}/message/test", ownerAuth(http.HandlerFunc(s.adminChannelMessageTest))) mux.Handle("PUT /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminSaveChannelConfig))) mux.Handle("DELETE /api/v1/admin/channels/{platform}/config", ownerAuth(http.HandlerFunc(s.adminDeleteChannelAccount))) mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall))) mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall))) mux.Handle("POST /api/v1/admin/memories", ownerAuth(http.HandlerFunc(s.adminAddMemory))) mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch))) mux.Handle("POST /api/v1/admin/rag", ownerAuth(http.HandlerFunc(s.adminIndexRag))) mux.Handle("POST /api/v1/admin/rag/delete", ownerAuth(http.HandlerFunc(s.adminDeleteRag))) mux.Handle("POST /api/v1/admin/system/revoke-keys", ownerAuth(http.HandlerFunc(s.adminRevokeAllKeys))) mux.Handle("POST /api/v1/admin/system/rotate-jwt", ownerAuth(http.HandlerFunc(s.adminRotateJWT))) mux.Handle("POST /api/v1/admin/system/backup", ownerAuth(http.HandlerFunc(s.adminBackup))) mux.Handle("POST /api/v1/admin/system/restore", ownerAuth(http.HandlerFunc(s.adminRestore))) } // mountWebhooks 挂载外部提供的入站回调(例如 webhook // 渠道)且不经网关认证;每个 handler 强制自己的 // 签名/授权。 func (s *Server) mountWebhooks(mux *http.ServeMux) { for path, hdl := range s.webhooks { if path == "" { continue } mux.Handle(path, hdl) } } // authorize 返回要求给定角色的凭据门。它由 Bearer JWT 门加上 `X-API-Key` // 分支组成(裁决 15.4.2):key 表已注入时两种凭据都收,否则只认 Bearer。 // 按裁决 15.4.3,任何有效 key 一律 owner 级(owner 满足一切角色要求), // 未知/已吊销 key 一律 401 且不回落 Bearer(fail-closed,见 auth.WithAPIKey)。 func (s *Server) authorize(required ...auth.Role) func(http.Handler) http.Handler { bearer := s.auth.Authorize(required...) keys := s.apiKeyTable() if keys == nil { return bearer } return auth.WithAPIKey(keys, bearer, s.log, required...) } // apiKeyTable 返回注入的 API key 表;未注入(或注入的实现不是 key 表)时为 // nil,此时鉴权只认 Bearer —— 「表不在」等同空表,绝不因此放行任何 key。 func (s *Server) apiKeyTable() auth.KeyTable { if s.users == nil { return nil } table, ok := s.users.(auth.KeyTable) if !ok { return nil } return table } // withMiddleware 把已路由的 handler 包进外层,最外层在前: // 上传能力 URL 防护、SPA 回退(当配置了前端时)、 // 安全入口(管理面板前缀)、panic recover、CORS 与请求日志。 func (s *Server) withMiddleware(mux http.Handler) http.Handler { var h http.Handler = mux h = guardUploadPaths(h) if s.frontend != nil { h = s.spa(h) // 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2):管理面板前端按 .env 的 // ADMIN_PATH 挂载(默认 /admin),猜错的前缀 404 而不回落用户端聊天壳。 h = s.adminEntryHatch(h, s.cfg.Server.AdminPathPrefix()) } h = s.recover(h) h = s.cors(h) h = s.logging(h) return h } // --- 安全入口(裁决 1.6 / 16.4.7,design/07 §0.2) --------------------------- // adminEntryGlobal 是注入面板入口页的全局变量名:SPA 据此判定「本页由安全 // 入口送达」并取得面板前缀。它是前缀抵达前端的唯一通道,因此该串不硬编进 // 用户端 bundle(见 frontend/web/src/admin/router.ts)。 const adminEntryGlobal = "__CYRENE_ADMIN_ENTRY__" // adminEntryHatch 把管理面板挂到配置的 ADMIN_PATH 前缀下: // - 前缀自身与其下的深链(/<前缀>/
)送出注入了入口信息的 // index.html,使真实路径直达面板而不回落用户端聊天壳; // - 形如面板入口但前缀不对的探测(例如前缀被改后仍被访问的 /admin) // 直接 404,既不回落聊天壳也不泄露正确前缀; // - 其余路径原样交给常规 SPA / API 处理。 func (s *Server) adminEntryHatch(next http.Handler, prefix string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path if isAPIRoute(p) || s.isWebhookRoute(p) { next.ServeHTTP(w, r) return } switch { case p == prefix || strings.HasPrefix(p, prefix+"/"): if s.adminEntryRealFile(p) || !s.serveAdminEntry(w, prefix) { next.ServeHTTP(w, r) } case isAdminShapedPath(p): http.NotFound(w, r) default: next.ServeHTTP(w, r) } }) } // adminEntryRealFile 报告 path 是否命中 dist 里的真实静态文件:前缀若恰好 // 撞上资源目录/文件名(例如把 ADMIN_PATH 配成 assets),文件服务器照常送 // 文件,绝不让面板入口页顶掉 SPA 的资源。 func (s *Server) adminEntryRealFile(p string) bool { rel := strings.TrimPrefix(path.Clean(p), "/") if rel == "" { return false } info, err := fs.Stat(s.frontend, rel) return err == nil && !info.IsDir() } // serveAdminEntry 送出管理面板入口页。返回 false 表示 index.html 读不出来 // (例如外置 dist 尚未构建),交由常规 SPA 处理。 func (s *Server) serveAdminEntry(w http.ResponseWriter, prefix string) bool { raw, err := fs.ReadFile(s.frontend, "index.html") if err != nil { return false } w.Header().Set("Content-Type", "text/html; charset=utf-8") // 入口页按请求注入(前缀 / 站点根锚点),不能缓存。 w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(adminEntryHTML(raw, prefix)) return true } // adminEntryHTML 在 `` 之后注入入口信息与 ``: // - 入口信息是内联经典脚本,必先于 Vite 的 deferred module 执行,前端启动 // 时即可读到面板前缀; // - `` 把相对资源锚回站点根:面板入口可能是多段路径 // (/xxx/admin)或深链(/admin/personas),否则 Vite 产出的 `./assets/...` // 会解析到错误目录(单二进制始终挂在站点根,design/13 §0)。 // // 找不到 `` 时注入到文档最前,同样先于任何资源引用。 func adminEntryHTML(indexHTML []byte, prefix string) []byte { payload, err := json.Marshal(map[string]string{"prefix": prefix}) if err != nil { // 字符串映射不会让 json.Marshal 失败;真出错时只保住 。 payload = []byte(`{}`) } snippet := []byte(``) out := make([]byte, 0, len(indexHTML)+len(snippet)) if i := bytes.Index(indexHTML, []byte("")); i >= 0 { at := i + len("") out = append(out, indexHTML[:at]...) out = append(out, snippet...) return append(out, indexHTML[at:]...) } out = append(out, snippet...) return append(out, indexHTML...) } // isAdminShapedPath 报告 path 是否形如一次管理面板入口尝试(路径里出现 // `admin` 段,如 /admin、/admin/x、/foo/admin):前缀被自定义后,这类猜测 // 一律 404,避免回落用户端聊天壳把探测结果变成「面板不存在」以外的信息。 func isAdminShapedPath(p string) bool { for _, seg := range strings.Split(p, "/") { if seg == config.DefaultAdminPath { return true } } return false }