feat: implement app access control and upload token validation in storage
This commit is contained in:
@@ -8,16 +8,17 @@ type Config struct {
|
||||
Type string
|
||||
|
||||
// Local settings
|
||||
LocalDir string
|
||||
ServerBaseURL string
|
||||
LocalDir string
|
||||
ServerBaseURL string
|
||||
UploadSecret string
|
||||
|
||||
// S3 settings
|
||||
S3Endpoint string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3UseSSL bool
|
||||
S3ReleaseBucket string
|
||||
S3PatchBucket string
|
||||
S3Endpoint string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3UseSSL bool
|
||||
S3ReleaseBucket string
|
||||
S3PatchBucket string
|
||||
}
|
||||
|
||||
// NewStore creates the appropriate Store backend based on Config.Type.
|
||||
@@ -32,6 +33,6 @@ func NewStore(cfg Config) (Store, error) {
|
||||
if cfg.Type != "" && cfg.Type != "local" {
|
||||
fmt.Printf("storage: unknown type %q, falling back to local\n", cfg.Type)
|
||||
}
|
||||
return NewLocalStore(cfg.LocalDir, cfg.ServerBaseURL)
|
||||
return NewLocalStore(cfg.LocalDir, cfg.ServerBaseURL, cfg.UploadSecret)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,11 +23,12 @@ type LocalStore struct {
|
||||
// serverBaseURL is the external URL of this server (e.g. http://localhost:8080).
|
||||
// Used when generating download URLs for device-side patch checks.
|
||||
serverBaseURL string
|
||||
uploadSecret string
|
||||
}
|
||||
|
||||
// NewLocalStore creates a local-filesystem storage backend.
|
||||
// baseDir is created automatically if it does not exist.
|
||||
func NewLocalStore(baseDir, serverBaseURL string) (*LocalStore, error) {
|
||||
func NewLocalStore(baseDir, serverBaseURL, uploadSecret string) (*LocalStore, error) {
|
||||
abs, err := filepath.Abs(baseDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local storage: %w", err)
|
||||
@@ -36,7 +42,7 @@ func NewLocalStore(baseDir, serverBaseURL string) (*LocalStore, error) {
|
||||
if serverBaseURL == "" {
|
||||
serverBaseURL = "http://localhost:8080"
|
||||
}
|
||||
return &LocalStore{baseDir: abs, serverBaseURL: serverBaseURL}, nil
|
||||
return &LocalStore{baseDir: abs, serverBaseURL: serverBaseURL, uploadSecret: uploadSecret}, nil
|
||||
}
|
||||
|
||||
func (s *LocalStore) BackendName() string { return "Local filesystem" }
|
||||
@@ -49,7 +55,12 @@ func (s *LocalStore) GeneratePresignedUploadURL(_ context.Context, objectKey str
|
||||
if isPublic {
|
||||
scope = "patches"
|
||||
}
|
||||
return fmt.Sprintf("%s/storage/upload/%s/%s", s.serverBaseURL, scope, objectKey), nil
|
||||
base := fmt.Sprintf("%s/storage/upload/%s/%s", s.serverBaseURL, scope, objectKey)
|
||||
if s.uploadSecret == "" {
|
||||
return base, nil
|
||||
}
|
||||
expires := time.Now().Add(30 * time.Minute).Unix()
|
||||
return fmt.Sprintf("%s?expires=%d&token=%s", base, expires, s.signUpload(scope, objectKey, expires)), nil
|
||||
}
|
||||
|
||||
// GeneratePublicDownloadURL returns a public download URL for a patch.
|
||||
@@ -63,7 +74,10 @@ func (s *LocalStore) UploadObject(_ context.Context, objectKey string, reader io
|
||||
if isPublic {
|
||||
scope = "patches"
|
||||
}
|
||||
target := filepath.Join(s.baseDir, scope, objectKey)
|
||||
target, err := s.objectPath(scope, objectKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("local storage: %w", err)
|
||||
}
|
||||
@@ -84,8 +98,50 @@ func (s *LocalStore) GetObject(_ context.Context, objectKey string, isPublic boo
|
||||
if isPublic {
|
||||
scope = "patches"
|
||||
}
|
||||
return os.Open(filepath.Join(s.baseDir, scope, objectKey))
|
||||
target, err := s.objectPath(scope, objectKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.Open(target)
|
||||
}
|
||||
|
||||
// BaseDir returns the absolute base directory.
|
||||
func (s *LocalStore) BaseDir() string { return s.baseDir }
|
||||
|
||||
func (s *LocalStore) ValidateUploadToken(scope, objectKey, token string, expires int64) bool {
|
||||
if s.uploadSecret == "" {
|
||||
return true
|
||||
}
|
||||
if token == "" || expires <= time.Now().Unix() {
|
||||
return false
|
||||
}
|
||||
expected := s.signUpload(scope, objectKey, expires)
|
||||
return hmac.Equal([]byte(token), []byte(expected))
|
||||
}
|
||||
|
||||
func (s *LocalStore) signUpload(scope, objectKey string, expires int64) string {
|
||||
mac := hmac.New(sha256.New, []byte(s.uploadSecret))
|
||||
io.WriteString(mac, scope)
|
||||
io.WriteString(mac, "\n")
|
||||
io.WriteString(mac, objectKey)
|
||||
io.WriteString(mac, "\n")
|
||||
io.WriteString(mac, strconv.FormatInt(expires, 10))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *LocalStore) objectPath(scope, objectKey string) (string, error) {
|
||||
if objectKey == "" || filepath.IsAbs(objectKey) {
|
||||
return "", fmt.Errorf("local storage: invalid object key")
|
||||
}
|
||||
cleanKey := filepath.Clean(filepath.FromSlash(objectKey))
|
||||
if cleanKey == "." || cleanKey == ".." || strings.HasPrefix(cleanKey, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("local storage: invalid object key")
|
||||
}
|
||||
root := filepath.Join(s.baseDir, scope)
|
||||
target := filepath.Join(root, cleanKey)
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("local storage: invalid object key")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user