Files
Cyrene_v2/internal/services/gateway/routes.go
T
AskaEth 407c934a05 feat(gateway): 文档存储端点 POST/GET /api/v1/documents(design/24 S1)
- 字节嗅探唯一裁决 + 扩展名白名单 deny-by-default(23 个文本/代码扩展名);
  客户端 Content-Type 与文件名只作参考、可被否决。
- 内容寻址落盘名 sha256[:32]+ext ⇒ 同字节同扩展名天然去重;首次 201/deduped:false,
  命中 200/同一 name/deduped:true。
- 独立限额 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB,与图片 5 MiB 刻意解耦)+ MaxBytesReader。
- 读侧 Bearer owner|admin + Content-Disposition: attachment + nosniff(与图片读侧刻意不同);
  写侧审计 document.upload;错误码 unsupported_media_type/document_too_large/document_not_found。
- 元数据表 documents(0014,含 idx_documents_name 唯一、idx_documents_sha256);
  guardDocumentPaths 防路径穿越(否则 ServeMux 会 307 泄漏路径)。
- 既有 /api/v1/upload 与 /api/v1/files/{name} 零改动(upload.go 零 diff)。

验收(主会话独立复跑,非自报):隔离 worktree(HEAD + 本切片)go build/vet 绿、
全树 58 包 0 FAIL、linux/amd64 + darwin/arm64 交叉编译绿、9 条新单测 PASS。
变异独立复现 3 条:白名单加 ".js" ⇒ 415 用例红;嗅探裁决一律放行 ⇒ 谎报类型用例红;
摘掉 guardDocumentPaths ⇒ 穿越用例红(307 重定向,正是那道防护存在的理由)。
2026-10-04 17:07:20 +08:00

387 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 / <img> 无法携带
// bearer token。二者都走同一个网关端口(单二进制红线)。
mux.Handle("POST /api/v1/upload", writeAuth(http.HandlerFunc(s.handleUpload)))
mux.HandleFunc("GET /api/v1/files/{name}", s.handleUploadFile)
// 文档存储(design/24 §3 / §6.1,切片 S1):与图片上传同一个数据目录根
// (<DataDir>/uploads/documents),但**写读两侧都要 owner/admin** —— 文档读侧
// 不需要被 <img>/provider 直接消费(摄取在服务端完成),因此不走图片那套
// 「随机名即凭据」的免鉴权读法,也不会放开内联服务 HTML/SVG 的口子。
mux.Handle("POST /api/v1/documents", writeAuth(http.HandlerFunc(s.handleDocumentUpload)))
mux.Handle("GET /api/v1/documents/{name}", writeAuth(http.HandlerFunc(s.handleDocumentFile)))
}
// 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)
// 文档路径的同类防护(design/24 §3.2「路径穿越」):`..` 形态必须在 ServeMux
// 清理路径并重定向之前被拦下,否则穿越请求会变成一个非文档路径的重定向。
h = guardDocumentPaths(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 前缀下:
// - 前缀自身与其下的深链(/<前缀>/<section>)送出注入了入口信息的
// 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 在 `<head>` 之后注入入口信息与 `<base href="/">`:
// - 入口信息是内联经典脚本,必先于 Vite 的 deferred module 执行,前端启动
// 时即可读到面板前缀;
// - `<base href="/">` 把相对资源锚回站点根:面板入口可能是多段路径
// (/xxx/admin)或深链(/admin/personas),否则 Vite 产出的 `./assets/...`
// 会解析到错误目录(单二进制始终挂在站点根,design/13 §0)。
//
// 找不到 `<head>` 时注入到文档最前,同样先于任何资源引用。
func adminEntryHTML(indexHTML []byte, prefix string) []byte {
payload, err := json.Marshal(map[string]string{"prefix": prefix})
if err != nil {
// 字符串映射不会让 json.Marshal 失败;真出错时只保住 <base>。
payload = []byte(`{}`)
}
snippet := []byte(`<base href="/"><script>window.` + adminEntryGlobal + "=" + string(payload) + `;</script>`)
out := make([]byte, 0, len(indexHTML)+len(snippet))
if i := bytes.Index(indexHTML, []byte("<head>")); i >= 0 {
at := i + len("<head>")
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
}