jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 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>
2026-05-01 11:21:20 +08:00

353 lines
10 KiB
Go
Raw Permalink 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.

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 1S3Store 會實作同 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 sidecarPhase 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 專用)。
//
// 參數:
// - methodHTTP method"GET" / "PUT"
// - keystorage key已 urldecode 過)
// - expiresURL 裡的 expires 參數
// - signatureURL 裡的 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)