diff --git a/apps/backend/cmd/drexa/main.go b/apps/backend/cmd/drexa/main.go index 10c74f4..7769b1d 100644 --- a/apps/backend/cmd/drexa/main.go +++ b/apps/backend/cmd/drexa/main.go @@ -1,19 +1,27 @@ package main import ( + "flag" "fmt" "log" + "os" "github.com/get-drexa/drexa/internal/drexa" - "github.com/joho/godotenv" ) func main() { - _ = godotenv.Load() + configPath := flag.String("config", "", "path to config file (required)") + flag.Parse() - config, err := drexa.ServerConfigFromEnv() + if *configPath == "" { + fmt.Fprintln(os.Stderr, "error: --config is required") + flag.Usage() + os.Exit(1) + } + + config, err := drexa.ConfigFromFile(*configPath) if err != nil { - log.Fatal(err) + log.Fatalf("failed to load config: %v", err) } server, err := drexa.NewServer(*config) @@ -21,5 +29,6 @@ func main() { log.Fatal(err) } - log.Fatal(server.Listen(fmt.Sprintf(":%d", config.Port))) + log.Printf("starting server on :%d", config.Server.Port) + log.Fatal(server.Listen(fmt.Sprintf(":%d", config.Server.Port))) } diff --git a/apps/backend/config.example.yaml b/apps/backend/config.example.yaml new file mode 100644 index 0000000..a0c11c7 --- /dev/null +++ b/apps/backend/config.example.yaml @@ -0,0 +1,30 @@ +# Drexa Backend Configuration +# Copy this file to config.yaml and adjust values for your environment. + +server: + port: 8080 + +database: + postgres_url: postgres://user:password@localhost:5432/drexa?sslmode=disable + +jwt: + issuer: drexa + audience: drexa-api + # Secret key can be provided via (in order of precedence): + # 1. JWT_SECRET_KEY environment variable (base64 encoded) + # 2. secret_key_base64 below (base64 encoded) + # 3. secret_key_path below (file with base64 encoded content) + # secret_key_base64: "base64encodedkey" + secret_key_path: /run/secrets/jwt_secret_key + +storage: + # Mode: "flat" (UUID-based keys) or "hierarchical" (path-based keys) + # Note: S3 backend only supports "flat" mode + mode: flat + # Backend: "fs" (filesystem) or "s3" (not yet implemented) + backend: fs + # Required when backend is "fs" + root_path: /var/lib/drexa/blobs + # Required when backend is "s3" + # bucket: my-drexa-bucket + diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 7a68bb7..2e747bd 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -3,16 +3,17 @@ module github.com/get-drexa/drexa go 1.25.4 require ( + github.com/gabriel-vasile/mimetype v1.4.11 github.com/gofiber/fiber/v2 v2.52.9 github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/sqids/sqids-go v0.4.1 github.com/uptrace/bun v1.2.15 golang.org/x/crypto v0.40.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/gabriel-vasile/mimetype v1.4.11 // indirect - github.com/joho/godotenv v1.5.1 // indirect - github.com/sqids/sqids-go v0.4.1 // indirect go.opentelemetry.io/otel v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect mellium.im/sasl v0.3.2 // indirect @@ -20,7 +21,7 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/jinzhu/inflection v1.0.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -28,7 +29,6 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/uptrace/bun/dialect/pgdialect v1.2.15 github.com/uptrace/bun/driver/pgdriver v1.2.15 diff --git a/apps/backend/go.sum b/apps/backend/go.sum index acfb399..a6eb4ea 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -8,6 +8,8 @@ github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5 github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -16,12 +18,16 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= @@ -59,6 +65,9 @@ golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+Zdx golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= diff --git a/apps/backend/internal/drexa/config.go b/apps/backend/internal/drexa/config.go new file mode 100644 index 0000000..222a7ce --- /dev/null +++ b/apps/backend/internal/drexa/config.go @@ -0,0 +1,155 @@ +package drexa + +import ( + "encoding/base64" + "errors" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type StorageMode string +type StorageBackend string + +const ( + StorageModeFlat StorageMode = "flat" + StorageModeHierarchical StorageMode = "hierarchical" +) + +const ( + StorageBackendFS StorageBackend = "fs" + StorageBackendS3 StorageBackend = "s3" +) + +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + JWT JWTConfig `yaml:"jwt"` + Storage StorageConfig `yaml:"storage"` +} + +type ServerConfig struct { + Port int `yaml:"port"` +} + +type DatabaseConfig struct { + PostgresURL string `yaml:"postgres_url"` +} + +type JWTConfig struct { + Issuer string `yaml:"issuer"` + Audience string `yaml:"audience"` + SecretKeyBase64 string `yaml:"secret_key_base64"` + SecretKeyPath string `yaml:"secret_key_path"` + SecretKey []byte `yaml:"-"` +} + +type StorageConfig struct { + Mode StorageMode `yaml:"mode"` + Backend StorageBackend `yaml:"backend"` + RootPath string `yaml:"root_path"` + Bucket string `yaml:"bucket"` +} + +// ConfigFromFile loads configuration from a YAML file. +// JWT secret key is loaded from JWT_SECRET_KEY env var (base64 encoded), +// falling back to the file path specified in jwt.secret_key_path. +func ConfigFromFile(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("config file not found: %s", path) + } + return nil, err + } + + var config Config + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, err + } + + // Load JWT secret key (priority: env var > config base64 > config file path) + if envKey := os.Getenv("JWT_SECRET_KEY"); envKey != "" { + key, err := base64.StdEncoding.DecodeString(envKey) + if err != nil { + return nil, errors.New("JWT_SECRET_KEY env var is not valid base64") + } + config.JWT.SecretKey = key + } else if config.JWT.SecretKeyBase64 != "" { + key, err := base64.StdEncoding.DecodeString(config.JWT.SecretKeyBase64) + if err != nil { + return nil, errors.New("jwt.secret_key_base64 is not valid base64") + } + config.JWT.SecretKey = key + } else if config.JWT.SecretKeyPath != "" { + keyData, err := os.ReadFile(config.JWT.SecretKeyPath) + if err != nil { + return nil, err + } + key, err := base64.StdEncoding.DecodeString(string(keyData)) + if err != nil { + return nil, errors.New("jwt.secret_key_path file content is not valid base64") + } + config.JWT.SecretKey = key + } + + if errs := config.Validate(); len(errs) > 0 { + return nil, NewConfigError(errs...) + } + + return &config, nil +} + +// Validate checks for required configuration fields. +func (c *Config) Validate() []error { + var errs []error + + // Server + if c.Server.Port == 0 { + errs = append(errs, errors.New("server.port is required")) + } + + // Database + if c.Database.PostgresURL == "" { + errs = append(errs, errors.New("database.postgres_url is required")) + } + + // JWT + if c.JWT.Issuer == "" { + errs = append(errs, errors.New("jwt.issuer is required")) + } + if c.JWT.Audience == "" { + errs = append(errs, errors.New("jwt.audience is required")) + } + if len(c.JWT.SecretKey) == 0 { + errs = append(errs, errors.New("jwt secret key is required (set JWT_SECRET_KEY env var, jwt.secret_key_base64, or jwt.secret_key_path)")) + } + + // Storage + if c.Storage.Mode == "" { + errs = append(errs, errors.New("storage.mode is required")) + } else if c.Storage.Mode != StorageModeFlat && c.Storage.Mode != StorageModeHierarchical { + errs = append(errs, errors.New("storage.mode must be 'flat' or 'hierarchical'")) + } + + if c.Storage.Backend == "" { + errs = append(errs, errors.New("storage.backend is required")) + } else if c.Storage.Backend != StorageBackendFS && c.Storage.Backend != StorageBackendS3 { + errs = append(errs, errors.New("storage.backend must be 'fs' or 's3'")) + } + + if c.Storage.Backend == StorageBackendFS && c.Storage.RootPath == "" { + errs = append(errs, errors.New("storage.root_path is required when backend is 'fs'")) + } + if c.Storage.Backend == StorageBackendS3 { + if c.Storage.Bucket == "" { + errs = append(errs, errors.New("storage.bucket is required when backend is 's3'")) + } + if c.Storage.Mode == StorageModeHierarchical { + errs = append(errs, errors.New("storage.mode must be 'flat' when backend is 's3'")) + } + } + + return errs +} diff --git a/apps/backend/internal/drexa/err.go b/apps/backend/internal/drexa/err.go index cde040a..dbabee4 100644 --- a/apps/backend/internal/drexa/err.go +++ b/apps/backend/internal/drexa/err.go @@ -5,17 +5,17 @@ import ( "strings" ) -type ServerConfigError struct { +type ConfigError struct { Errors []error } -func NewServerConfigError(errs ...error) *ServerConfigError { - return &ServerConfigError{Errors: errs} +func NewConfigError(errs ...error) *ConfigError { + return &ConfigError{Errors: errs} } -func (e *ServerConfigError) Error() string { +func (e *ConfigError) Error() string { sb := strings.Builder{} - sb.WriteString("invalid server config:\n") + sb.WriteString("invalid config:\n") for _, err := range e.Errors { sb.WriteString(fmt.Sprintf(" - %s\n", err.Error())) } diff --git a/apps/backend/internal/drexa/server.go b/apps/backend/internal/drexa/server.go index 2eb59c0..7117999 100644 --- a/apps/backend/internal/drexa/server.go +++ b/apps/backend/internal/drexa/server.go @@ -1,11 +1,7 @@ package drexa import ( - "encoding/hex" - "errors" "fmt" - "os" - "strconv" "github.com/get-drexa/drexa/internal/auth" "github.com/get-drexa/drexa/internal/blob" @@ -16,24 +12,34 @@ import ( "github.com/gofiber/fiber/v2" ) -type ServerConfig struct { - Port int - PostgresURL string - JWTIssuer string - JWTAudience string - JWTSecretKey []byte -} - -func NewServer(c ServerConfig) (*fiber.App, error) { +func NewServer(c Config) (*fiber.App, error) { app := fiber.New() - db := database.NewFromPostgres(c.PostgresURL) + db := database.NewFromPostgres(c.Database.PostgresURL) + + // Initialize blob store based on config + var blobStore blob.Store + switch c.Storage.Backend { + case StorageBackendFS: + blobStore = blob.NewFSStore(blob.FSStoreConfig{ + Root: c.Storage.RootPath, + }) + case StorageBackendS3: + return nil, fmt.Errorf("s3 storage backend not yet implemented") + default: + return nil, fmt.Errorf("unknown storage backend: %s", c.Storage.Backend) + } + + // Initialize key resolver based on config + var keyResolver virtualfs.BlobKeyResolver + switch c.Storage.Mode { + case StorageModeFlat: + keyResolver = virtualfs.NewFlatKeyResolver() + case StorageModeHierarchical: + keyResolver = virtualfs.NewHierarchicalKeyResolver(db) + default: + return nil, fmt.Errorf("unknown storage mode: %s", c.Storage.Mode) + } - // TODO: load correct blob store and resolver from config - blobStore := blob.NewFSStore(blob.FSStoreConfig{ - Root: os.Getenv("BLOB_ROOT"), - UploadURL: os.Getenv("BLOB_UPLOAD_URL"), - }) - keyResolver := virtualfs.NewFlatKeyResolver() vfs, err := virtualfs.NewVirtualFS(db, blobStore, keyResolver) if err != nil { return nil, fmt.Errorf("failed to create virtual file system: %w", err) @@ -41,9 +47,9 @@ func NewServer(c ServerConfig) (*fiber.App, error) { userService := user.NewService(db) authService := auth.NewService(db, userService, auth.TokenConfig{ - Issuer: c.JWTIssuer, - Audience: c.JWTAudience, - SecretKey: c.JWTSecretKey, + Issuer: c.JWT.Issuer, + Audience: c.JWT.Audience, + SecretKey: c.JWT.SecretKey, }) uploadService := upload.NewService(vfs, blobStore) @@ -53,50 +59,3 @@ func NewServer(c ServerConfig) (*fiber.App, error) { return app, nil } - -// ServerConfigFromEnv creates a ServerConfig from environment variables. -func ServerConfigFromEnv() (*ServerConfig, error) { - c := ServerConfig{ - PostgresURL: os.Getenv("POSTGRES_URL"), - JWTIssuer: os.Getenv("JWT_ISSUER"), - JWTAudience: os.Getenv("JWT_AUDIENCE"), - } - - errs := []error{} - - keyHex := os.Getenv("JWT_SECRET_KEY") - if keyHex == "" { - errs = append(errs, errors.New("JWT_SECRET_KEY is required")) - } else { - k, err := hex.DecodeString(keyHex) - if err != nil { - errs = append(errs, fmt.Errorf("failed to decode JWT_SECRET_KEY: %w", err)) - } - c.JWTSecretKey = k - } - - p, err := strconv.Atoi(os.Getenv("PORT")) - if err != nil { - errs = append(errs, fmt.Errorf("failed to parse PORT: %w", err)) - } - c.Port = p - - if c.PostgresURL == "" { - errs = append(errs, errors.New("POSTGRES_URL is required")) - } - if c.JWTIssuer == "" { - errs = append(errs, errors.New("JWT_ISSUER is required")) - } - if c.JWTAudience == "" { - errs = append(errs, errors.New("JWT_AUDIENCE is required")) - } - if len(c.JWTSecretKey) == 0 { - errs = append(errs, errors.New("JWT_SECRET_KEY is required")) - } - - if len(errs) > 0 { - return nil, NewServerConfigError(errs...) - } - - return &c, nil -} diff --git a/apps/backend/internal/virtualfs/key_resolver.go b/apps/backend/internal/virtualfs/key_resolver.go index d2a3967..6ceaa51 100644 --- a/apps/backend/internal/virtualfs/key_resolver.go +++ b/apps/backend/internal/virtualfs/key_resolver.go @@ -38,6 +38,10 @@ type HierarchicalKeyResolver struct { db *bun.DB } +func NewHierarchicalKeyResolver(db *bun.DB) *HierarchicalKeyResolver { + return &HierarchicalKeyResolver{db: db} +} + func (r *HierarchicalKeyResolver) KeyMode() blob.KeyMode { return blob.KeyModeDerived }