package storage import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "io/fs" "net/url" "os" "path/filepath" "strings" "time" ) // LocalFSStore 是以本地 filesystem 為後端的 Store 實作(Phase 0 雛形)。 // // 特性: // - 檔案存於 root + key 組成的路徑下(root 預設 ./data/storage) // - meta 存為 sidecar 檔:`{path}.meta.json`(雛形簡化;S3 原生支援 metadata) // - Presigned URL 使用 HMAC-SHA256 簽名,api-server 的 /storage handler 驗證 // // Phase 1:S3Store 會實作同 interface 取代之。 type LocalFSStore struct { root string baseURL string signer *Signer } // NewLocalFSStore 建立一個 LocalFSStore。 // // root 為儲存根目錄(不存在會自動建立);baseURL 用於 presigned URL 前綴; // signingSecret 為 HMAC 簽名 secret(生產環境必須由 env 提供,不可使用預設值)。 func NewLocalFSStore(root, baseURL, signingSecret string) (*LocalFSStore, error) { if root == "" { return nil, errors.New("storage: root must not be empty") } absRoot, err := filepath.Abs(root) if err != nil { return nil, fmt.Errorf("storage: resolve root abs path: %w", err) } if err := os.MkdirAll(absRoot, 0o755); err != nil { return nil, fmt.Errorf("storage: mkdir root: %w", err) } if signingSecret == "" { signingSecret = "dev-signing-secret-do-not-use-in-prod" } return &LocalFSStore{ root: absRoot, baseURL: strings.TrimRight(baseURL, "/"), signer: NewSigner([]byte(signingSecret)), }, nil } // resolveKey 將 key 轉成絕對路徑,並驗證未逃出 root(防止 path traversal)。 // // 允許 key 為空字串(回 root 本身,供 List 使用全量掃描)。 func (s *LocalFSStore) resolveKey(key string) (string, error) { if strings.Contains(key, "\x00") { return "", ErrInvalidKey } // 明確拒絕任何包含 ".." 的 path segment — 防止絕對路徑逃出 root // (即使 filepath.Clean 會 normalize,保險起見先在此層阻擋)。 if containsParentDir(key) { return "", ErrInvalidKey } // 拒絕絕對路徑開頭 if strings.HasPrefix(key, "/") || strings.HasPrefix(key, string(filepath.Separator)) { return "", ErrInvalidKey } if key == "" { return s.root, nil } full := filepath.Join(s.root, key) absFull, err := filepath.Abs(full) if err != nil { return "", ErrInvalidKey } // 確保最終路徑仍在 root 底下(雙重保險) if absFull != s.root && !strings.HasPrefix(absFull, s.root+string(filepath.Separator)) { return "", ErrInvalidKey } return absFull, nil } // containsParentDir 回報 key 是否含有 ".." segment(用 / 與 OS separator 兩種分隔符)。 func containsParentDir(key string) bool { for _, sep := range []string{"/", string(filepath.Separator)} { for _, seg := range strings.Split(key, sep) { if seg == ".." { return true } } } return false } // Put 寫入 object;路徑不存在會自動建立父目錄。 func (s *LocalFSStore) Put(ctx context.Context, key string, r io.Reader, size int64, meta map[string]string) error { if key == "" { return ErrInvalidKey } fullPath, err := s.resolveKey(key) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { return fmt.Errorf("storage: mkdir: %w", err) } f, err := os.Create(fullPath) if err != nil { return fmt.Errorf("storage: create file: %w", err) } defer f.Close() if _, err := io.Copy(f, r); err != nil { return fmt.Errorf("storage: write file: %w", err) } // 雛形暫不寫 meta sidecar(Phase 1 按需要實作)。 // 若要寫,對齊 storage.md §3:{path}.meta.json return nil } // Get 開啟一個 object 讀取 reader 並回傳 metadata。 func (s *LocalFSStore) Get(ctx context.Context, key string) (io.ReadCloser, *Object, error) { if key == "" { return nil, nil, ErrInvalidKey } fullPath, err := s.resolveKey(key) if err != nil { return nil, nil, err } info, err := os.Stat(fullPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, nil, ErrNotFound } return nil, nil, fmt.Errorf("storage: stat: %w", err) } f, err := os.Open(fullPath) if err != nil { return nil, nil, fmt.Errorf("storage: open file: %w", err) } obj := &Object{ Key: key, Size: info.Size(), ContentType: "application/octet-stream", // 雛形預設;Phase 1 讀 sidecar LastModified: info.ModTime().UTC(), } return f, obj, nil } // Stat 回傳 metadata,不開啟內容。 func (s *LocalFSStore) Stat(ctx context.Context, key string) (*Object, error) { if key == "" { return nil, ErrInvalidKey } fullPath, err := s.resolveKey(key) if err != nil { return nil, err } info, err := os.Stat(fullPath) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, ErrNotFound } return nil, fmt.Errorf("storage: stat: %w", err) } return &Object{ Key: key, Size: info.Size(), ContentType: "application/octet-stream", LastModified: info.ModTime().UTC(), }, nil } // Exists 判斷 object 是否存在。 // // 語意對齊 storage.md §1 的 ObjectStorage.Exists: // - 檔案存在 → (true, nil) // - 檔案不存在 → (false, nil)(非 error) // - 其他 IO 錯誤 → (false, err) func (s *LocalFSStore) Exists(ctx context.Context, key string) (bool, error) { if key == "" { return false, ErrInvalidKey } fullPath, err := s.resolveKey(key) if err != nil { return false, err } _, err = os.Stat(fullPath) if err == nil { return true, nil } if errors.Is(err, fs.ErrNotExist) { return false, nil } return false, fmt.Errorf("storage: stat: %w", err) } // Delete 刪除 object;不存在視為成功(no-op)。 func (s *LocalFSStore) Delete(ctx context.Context, key string) error { if key == "" { return ErrInvalidKey } fullPath, err := s.resolveKey(key) if err != nil { return err } if err := os.Remove(fullPath); err != nil { if errors.Is(err, fs.ErrNotExist) { return nil } return fmt.Errorf("storage: remove: %w", err) } return nil } // List 列出指定 prefix 下的所有 object(遞迴)。 // // 雛形實作簡化:以 filepath.Walk 掃描 root + prefix 資料夾。 // Phase 1 的 S3Store 可直接用原生 ListObjects API。 func (s *LocalFSStore) List(ctx context.Context, prefix string) ([]*Object, error) { // prefix 允許為空(列全部)。 base, err := s.resolveKey(prefix) if err != nil { // prefix 不合法 return nil, err } // 如果 prefix 指向的路徑不存在,回空 list(不是錯)。 if fi, statErr := os.Stat(base); statErr != nil || !fi.IsDir() { if statErr != nil && errors.Is(statErr, fs.ErrNotExist) { return []*Object{}, nil } // 若 prefix 指向檔案 → 回單筆 if statErr == nil && !fi.IsDir() { rel, _ := filepath.Rel(s.root, base) return []*Object{{ Key: filepath.ToSlash(rel), Size: fi.Size(), ContentType: "application/octet-stream", LastModified: fi.ModTime().UTC(), }}, nil } } out := make([]*Object, 0) err = filepath.Walk(base, func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } if info.IsDir() { return nil } rel, relErr := filepath.Rel(s.root, path) if relErr != nil { return relErr } out = append(out, &Object{ Key: filepath.ToSlash(rel), Size: info.Size(), ContentType: "application/octet-stream", LastModified: info.ModTime().UTC(), }) return nil }) if err != nil && !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("storage: walk: %w", err) } return out, nil } // PresignedGetURL 產生一個附 HMAC 簽名的下載 URL(雛形 LocalFS 實作)。 // // 格式:{baseURL}/{escaped-key}?expires={unix}&signature={base64url-hmac} // api-server 的 /storage/*filepath handler 負責驗證(見 storage.md §3.1)。 func (s *LocalFSStore) PresignedGetURL(ctx context.Context, key string, ttl time.Duration) (string, error) { return s.presignedURL("GET", key, ttl) } // PresignedPutURL 產生一個附簽名的上傳 URL。 func (s *LocalFSStore) PresignedPutURL(ctx context.Context, key string, ttl time.Duration) (string, error) { return s.presignedURL("PUT", key, ttl) } func (s *LocalFSStore) presignedURL(method, key string, ttl time.Duration) (string, error) { if key == "" { return "", ErrInvalidKey } if _, err := s.resolveKey(key); err != nil { return "", err } expiresAt := time.Now().UTC().Add(ttl).Unix() sig := s.signer.Sign(fmt.Sprintf("%s\n%s\n%d", method, key, expiresAt)) escaped := url.PathEscape(key) u := fmt.Sprintf("%s/%s?expires=%d&signature=%s", s.baseURL, escaped, expiresAt, sig) if method == "PUT" { u += "&mode=put" } return u, nil } // VerifySignature 供 api-server 的 /storage handler 呼叫(LocalFS 專用)。 // // 參數: // - method:HTTP method("GET" / "PUT") // - key:storage key(已 urldecode 過) // - expires:URL 裡的 expires 參數 // - signature:URL 裡的 signature 參數(base64url) // // 回 nil 表驗證通過;否則回 ErrInvalidSignature(或過期)。 func (s *LocalFSStore) VerifySignature(method, key string, expires int64, signature string) error { if time.Now().UTC().Unix() > expires { return ErrInvalidSignature } expected := s.signer.Sign(fmt.Sprintf("%s\n%s\n%d", method, key, expires)) if !hmac.Equal([]byte(expected), []byte(signature)) { return ErrInvalidSignature } return nil } // ========================================================================== // Signer — HMAC-SHA256 for LocalFS presigned URL // ========================================================================== // Signer 封裝 HMAC-SHA256 簽名流程;輸出為 base64url(無 padding)。 type Signer struct { secret []byte } // NewSigner 建立簽名器;secret 應具備足夠長度(建議 >= 32 bytes)。 func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} } // Sign 對 payload 產出 base64url-nopad 的 HMAC-SHA256 簽名。 func (s *Signer) Sign(payload string) string { mac := hmac.New(sha256.New, s.secret) mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } // 編譯時檢查:確保 LocalFSStore 實作 Store。 var _ Store = (*LocalFSStore)(nil)