從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
353 lines
10 KiB
Go
353 lines
10 KiB
Go
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)
|