# 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>
107 lines
4.2 KiB
Go
107 lines
4.2 KiB
Go
// 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
|
||
}
|