diff --git a/middleware/installguard.go b/middleware/installguard.go index cae49e6..2ef302e 100644 --- a/middleware/installguard.go +++ b/middleware/installguard.go @@ -8,9 +8,12 @@ import ( "github.com/volcano-tts/tts-api/installer" ) -// InstallGuard 拦截所有非 /setup 路由,在安装模式下返回 503。 -// 设计:放行白名单路径前缀,其余一律 503 + 引导跳转。 +// InstallGuard 拦截所有非白名单路由,在安装模式下按 Accept 头做内容协商: // +// - text/html 类(浏览器) → 302 重定向到 /setup +// - 其它(API 客户端、curl 等) → 503 + JSON +// +// 设计:放行白名单路径前缀,其余一律拦截。 // 中间件顺序:必须装在 RateLimit / ConcurrencyLimit / Logger 之前, // 避免安装模式下被限流计数污染(参考 M1 风险点 #2)。 func InstallGuard(currentMode func() installer.Mode, allowPrefixes ...string) func(http.Handler) http.Handler { @@ -37,10 +40,42 @@ func InstallGuard(currentMode func() installer.Mode, allowPrefixes ...string) fu return } } - log.Printf("[installguard] 安装模式下拒绝非白名单请求 - 路径=%s 客户端=%s", path, GetClientIP(r)) + log.Printf("[installguard] 安装模式下拒绝非白名单请求 - 路径=%s 客户端=%s accept=%q", + path, GetClientIP(r), r.Header.Get("Accept")) + // 内容协商:浏览器自动跳 /setup,API 客户端拿 JSON。 + // / 不在白名单里,所以这里同时覆盖"敲域名根路径"和"敲其他路径"两种场景。 + if acceptsHTML(r.Header.Get("Accept")) { + w.Header().Set("Location", "/setup") + w.WriteHeader(http.StatusFound) // 302 + return + } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"error":"not installed","code":"install_required","redirect":"/setup"}`)) }) } } + +// acceptsHTML 判断客户端是否接受 HTML 响应。 +// 严格匹配:Accept 必须显式包含 text/html 或 text/*,避免通配 */*(curl/API 默认) +// 走 302 路径影响 API 行为。 +func acceptsHTML(accept string) bool { + if accept == "" { + return false + } + for _, part := range strings.Split(accept, ",") { + mt := strings.TrimSpace(part) + if mt == "" { + continue + } + // 去掉 q= 等参数 + if idx := strings.Index(mt, ";"); idx >= 0 { + mt = strings.TrimSpace(mt[:idx]) + } + mt = strings.ToLower(mt) + if mt == "text/html" || mt == "text/*" { + return true + } + } + return false +} diff --git a/router/router.go b/router/router.go index 8c71485..cb5121b 100644 --- a/router/router.go +++ b/router/router.go @@ -3,6 +3,7 @@ package router import ( _ "embed" "net/http" + "strings" "github.com/gorilla/mux" "github.com/volcano-tts/tts-api/controller" @@ -30,8 +31,25 @@ func Setup() *mux.Router { r.Use(middleware.ConcurrencyLimitWithMetrics) r.Use(middleware.Logger) + // mux 的 NotFoundHandler 不会走 r.Use() 中间件链, + // 所以 InstallGuard 的内容协商在 404 路径上不生效。 + // 手动设一个:安装模式 + 浏览器访问任意未注册路径 → 302 跳 /setup。 + r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if installer.GetMode() == installer.ModeSetup && acceptsHTML(r.Header.Get("Accept")) { + http.Redirect(w, r, "/setup", http.StatusFound) + return + } + http.NotFound(w, r) + }) + // 安装相关路由(InstallGuard 已在 setup 模式放行;完成后由 controller 二次校验 404) + // /setup 页面本身:装完后必须不可用,否则用户敲 /setup 还会看到安装表单,容易误以为要重装。 + // 装后跳 /admin(M2 之后才有;目前会 404,这是预期,比继续显示表单好)。 r.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) { + if installer.GetMode() == installer.ModeNormal { + http.Redirect(w, r, "/admin", http.StatusFound) + return + } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(setupHTML) }).Methods("GET") @@ -61,3 +79,26 @@ func Setup() *mux.Router { return r } + +// acceptsHTML 在 router 包内复刻一份,middleware 包的版本未导出。 +// 用途:NotFoundHandler 判断浏览器 Accept。 +// 与 middleware.acceptsHTML 行为一致(简单实现,严格匹配 text/html 或 text/*)。 +func acceptsHTML(accept string) bool { + if accept == "" { + return false + } + for _, part := range strings.Split(accept, ",") { + mt := strings.TrimSpace(part) + if mt == "" { + continue + } + if idx := strings.Index(mt, ";"); idx >= 0 { + mt = strings.TrimSpace(mt[:idx]) + } + mt = strings.ToLower(mt) + if mt == "text/html" || mt == "text/*" { + return true + } + } + return false +}