visionA/visionA-backend/internal/api/preset_models.go
jim800121chen 9ab1a11ea4 feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)
# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token)
模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。
補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json):
- metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複
- 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY)
- download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權):
  download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501
- 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition
  attachment 用 mime.FormatMediaType、path-traversal 防禦)
- list/get 含 preset(對所有人可見)

# B5 詳細頁下載按鈕 + preset 可下載
- model-detail-client.tsx 補下載按鈕(行為對齊列表卡片)
- isModelDownloadable 加 preset(source==="preset" → true)
- normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520
  在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫)

# 測試
backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠
frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS
Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題)

# backlog(未做的優化)
preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:38:25 +08:00

107 lines
4.2 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.

// preset_models.go — GET /preset-models/* 公用靜態下載B8 系統預設模型)。
//
// 設計(使用者拍板「簡單版」):
// - 7 個 preset 的 .nef 打包進 imagePresetModelsDir = ./assets/preset-models
// - 此 handler 直接從該目錄串流檔案,**不簽 token、不走 AuthMiddleware**——preset 是
// 公用資料、本來就不需授權(對比 /storage/* 用 HMAC 簽章控管 user 私有檔案)。
// - download handlermodels.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-1PresetModelsDir 或 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 injectionCRLF / 引號破壞 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
}