// preset_models.go — GET /preset-models/* 公用靜態下載(B8 系統預設模型)。 // // 設計(使用者拍板「簡單版」): // - 7 個 preset 的 .nef 打包進 image(PresetModelsDir = ./assets/preset-models)。 // - 此 handler 直接從該目錄串流檔案,**不簽 token、不走 AuthMiddleware**——preset 是 // 公用資料、本來就不需授權(對比 /storage/* 用 HMAC 簽章控管 user 私有檔案)。 // - download handler(models.go)對 preset model 回 `{PresetBaseURL}/preset-models/{id}.nef`, // 前端拿 URL 直接導航下載(前端流程與既有 converted 類一致,只是 URL 來源不同)。 // // 安全:仍做 path-traversal 防禦(拒絕 ".." / 絕對路徑 / NUL),即使是公用檔,也不能讓 // 任意路徑逃出 PresetModelsDir 讀到 image 其他檔案。 package api import ( "mime" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" ) // registerPresetModelRoutes 註冊 GET /preset-models/*filepath。 // // 註冊條件與 download handler 的 501 條件對稱(Minor-1):PresetModelsDir 或 PresetBaseURL // 任一為空都視為「preset 未完整配置」→ 不註冊路由、download 也回 501。避免「download 501 // 但 /preset-models/ route 仍活著」的狀態不一致。 func registerPresetModelRoutes(r *gin.Engine, deps Deps) { if deps.PresetModelsDir == "" || deps.PresetBaseURL == "" { return } r.GET("/preset-models/*filepath", presetModelGetHandler(deps)) } // presetModelGetHandler 從 PresetModelsDir 串流 .nef 檔(公用、不驗簽)。 func presetModelGetHandler(deps Deps) gin.HandlerFunc { // 解析成絕對 root,供 path-traversal 比對。 absRoot, rootErr := filepath.Abs(deps.PresetModelsDir) return func(c *gin.Context) { if rootErr != nil { logOrDefault(deps.Logger).Error("preset: invalid PresetModelsDir", "dir", deps.PresetModelsDir, "err", rootErr.Error(), "request_id", RequestIDFrom(c)) WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, "preset models dir misconfigured", nil) return } raw := strings.TrimPrefix(c.Param("filepath"), "/") if raw == "" { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "empty filename", nil) return } // Path-traversal 防禦:拒絕 NUL / ".." segment / 絕對路徑開頭。 if strings.Contains(raw, "\x00") || hasParentDirSegment(raw) || strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, string(filepath.Separator)) { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid filename", nil) return } full := filepath.Join(absRoot, raw) // 雙重保險:確保最終路徑仍在 root 底下。 if full != absRoot && !strings.HasPrefix(full, absRoot+string(filepath.Separator)) { WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid filename", nil) return } info, err := os.Stat(full) if err != nil || info.IsDir() { // 不洩漏底層錯誤細節;不存在 / 是目錄都回 404。 WriteError(c, http.StatusNotFound, ErrCodeNotFound, "preset model not found", nil) return } logOrDefault(deps.Logger).Info("preset: download", "file", raw, "size", info.Size(), "request_id", RequestIDFrom(c)) // 設 attachment 讓瀏覽器導航時觸發下載(檔名 = preset 檔名)。 // 用 mime.FormatMediaType 正確 quote/escape 檔名(Minor-2 防禦性)——現況 raw 已過 // path-traversal 過濾、preset 檔名固定 {id}.nef 安全字元,但避免未來檔名來源放寬時的 // header injection(CRLF / 引號破壞 header 結構)。 disposition := mime.FormatMediaType("attachment", map[string]string{ "filename": filepath.Base(raw), }) if disposition == "" { // FormatMediaType 對非法值回空字串(理論上 raw 已過濾不會發生);fallback 不帶檔名。 disposition = "attachment" } c.Header("Content-Disposition", disposition) c.File(full) } } // hasParentDirSegment 回報 path 是否含有 ".." segment("/" 與 OS separator 兩種分隔符)。 func hasParentDirSegment(p string) bool { for _, sep := range []string{"/", string(filepath.Separator)} { for _, seg := range strings.Split(p, sep) { if seg == ".." { return true } } } return false }