Repository: GopeedLab/gopeed

Issue:
建议容器设置账号密码等参数使用环境变量
建议容器使用环境变量environment设置启动参数，而不是command，环境变量可以使用env_file文件承载，隐藏账号密码api等，提升安全。

Discussion:

Solution patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ab255c9..49a27c5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -53,7 +53,13 @@ jobs:
         with:
           go-version: ${{ env.GO_VERSION }}
       - name: Run Unit tests.
-        run: go test -v -coverpkg=$(go list ./... | grep -v /cmd/ | grep -v /bind/ | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $(go list ./... | grep -v /cmd/ | grep -v /bind/)
+        run: |
+          # Get packages for testing and coverage (including cmd/web for flags_test.go)
+          PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
+          go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
+          # Filter out main.go from coverage report to avoid lowering coverage percentage
+          grep -v "main.go" coverage.txt > coverage_filtered.txt || true
+          mv coverage_filtered.txt coverage.txt
       - uses: codecov/codecov-action@v4
         with:
           files: ./coverage.txt
@@ -63,8 +69,8 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        os: [ windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm ]
-    needs: [ test ]
+        os: [windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm]
+    needs: [test]
     steps:
       - uses: actions/checkout@v3
       - uses: actions/setup-go@v4
@@ -96,8 +102,8 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        os: [ macos-14, ubuntu-latest ]
-    needs: [ test ]
+        os: [macos-14, ubuntu-latest]
+    needs: [test]
     steps:
       - uses: actions/checkout@v3
       - uses: actions/setup-go@v4
@@ -129,7 +135,7 @@ jobs:
   build-web:
     name: Build web check
     runs-on: ubuntu-latest
-    needs: [ test ]
+    needs: [test]
     steps:
       - uses: actions/checkout@v3
       - uses: actions/setup-go@v4
@@ -149,7 +155,7 @@ jobs:
   build-docker:
     name: Build docker check
     runs-on: ubuntu-latest
-    needs: [ test ]
+    needs: [test]
     steps:
       - uses: actions/checkout@v3
       - uses: actions/setup-go@v4
diff --git a/cmd/web/flags.go b/cmd/web/flags.go
index 49af137..a802c51 100644
--- a/cmd/web/flags.go
+++ b/cmd/web/flags.go
@@ -3,19 +3,23 @@ package main
 import (
 	"encoding/json"
 	"flag"
-	"fmt"
-	"github.com/GopeedLab/gopeed/pkg/base"
 	"os"
 	"path/filepath"
+	"reflect"
+	"strconv"
+	"strings"
+
+	"github.com/GopeedLab/gopeed/pkg/base"
 )
 
 type args struct {
-	Address    *string `json:"address"`
-	Port       *int    `json:"port"`
-	Username   *string `json:"username"`
-	Password   *string `json:"password"`
-	ApiToken   *string `json:"apiToken"`
-	StorageDir *string `json:"storageDir"`
+	Address           *string  `json:"address"`
+	Port              *int     `json:"port"`
+	Username          *string  `json:"username"`
+	Password          *string  `json:"password"`
+	ApiToken          *string  `json:"apiToken"`
+	StorageDir        *string  `json:"storageDir"`
+	WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
 	// DownloadConfig when the first time to start the server, it will be configured as initial value
 	DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
 
@@ -23,62 +27,177 @@ type args struct {
 }
 
 func parse() *args {
-	var cliArgs args
-	cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
-	cliArgs.Port = flag.Int("P", 9999, "Bind Port")
-	cliArgs.Username = flag.String("u", "gopeed", "Web Authentication Username")
-	cliArgs.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
-	cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
-	cliArgs.StorageDir = flag.String("d", "", "Storage directory")
-	cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
+	cfg := &args{}
+
+	cliConfig := loadCliArgs()
+	loadConfigFile(cfg, *cliConfig.configPath)
+	loadEnvVars(cfg)
+	// override with non-default command line arguments
+	overrideWithCliArgs(cfg, cliConfig)
+	// set default values for any unset fields
+	setDefaults(cfg)
+	return cfg
+}
+
+// loadCliArgs parses command line arguments and returns initial config
+func loadCliArgs() *args {
+	cfg := &args{}
+	cfg.Address = flag.String("A", "", "Bind Address")
+	cfg.Port = flag.Int("P", 0, "Bind Port")
+	cfg.Username = flag.String("u", "", "Web Authentication Username")
+	cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
+	cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
+	cfg.StorageDir = flag.String("d", "", "Storage directory")
+	whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
+	cfg.configPath = flag.String("c", "./config.json", "Config file path")
 	flag.Parse()
 
-	// args priority: config file > cli args
-	cfgArgs := loadConfig(*cliArgs.configPath)
-	if cfgArgs.Address == nil {
-		cfgArgs.Address = cliArgs.Address
+	// Parse white download directories from comma-separated string
+	if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
+		dirs := strings.Split(*whiteDownloadDirs, ",")
+		for i := range dirs {
+			dirs[i] = strings.TrimSpace(dirs[i])
+		}
+		cfg.WhiteDownloadDirs = dirs
+	}
+
+	return cfg
+}
+
+// overrideWithCliArgs overrides config with non-empty command line arguments
+func overrideWithCliArgs(cfg *args, cliConfig *args) {
+	// Only override if the cli value is not empty/zero
+	if cliConfig.Address != nil && *cliConfig.Address != "" {
+		cfg.Address = cliConfig.Address
 	}
-	if cfgArgs.Port == nil {
-		cfgArgs.Port = cliArgs.Port
+
+	if cliConfig.Port != nil && *cliConfig.Port != 0 {
+		cfg.Port = cliConfig.Port
 	}
-	if cfgArgs.Username == nil {
-		cfgArgs.Username = cliArgs.Username
+
+	if cliConfig.Username != nil && *cliConfig.Username != "" {
+		cfg.Username = cliConfig.Username
+	}
+
+	if cliConfig.Password != nil && *cliConfig.Password != "" {
+		cfg.Password = cliConfig.Password
 	}
-	if cfgArgs.Password == nil {
-		cfgArgs.Password = cliArgs.Password
+
+	if cliConfig.ApiToken != nil && *cliConfig.ApiToken != "" {
+		cfg.ApiToken = cliConfig.ApiToken
 	}
-	if cfgArgs.ApiToken == nil {
-		cfgArgs.ApiToken = cliArgs.ApiToken
+
+	if cliConfig.StorageDir != nil && *cliConfig.StorageDir != "" {
+		cfg.StorageDir = cliConfig.StorageDir
 	}
-	if cfgArgs.StorageDir == nil {
-		cfgArgs.StorageDir = cliArgs.StorageDir
+
+	if cliConfig.WhiteDownloadDirs != nil {
+		cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
 	}
-	return cfgArgs
 }
 
-func loadConfig(path string) *args {
-	var args args
+// setDefaults sets default values for any unset configuration fields
+func setDefaults(cfg *args) {
+	if cfg.Address == nil {
+		address := "0.0.0.0"
+		cfg.Address = &address
+	}
+
+	if cfg.Port == nil {
+		port := 9999
+		cfg.Port = &port
+	}
 
-	if !filepath.IsAbs(path) {
+	if cfg.Username == nil {
+		username := "gopeed"
+		cfg.Username = &username
+	}
+}
+
+// loadConfigFile loads configuration from file
+func loadConfigFile(cfg *args, configPath string) {
+	if !filepath.IsAbs(configPath) {
 		dir, err := os.Getwd()
 		if err != nil {
-			fmt.Println("config dir get failed, reason:" + err.Error())
-			return &args
+			return
 		}
-		path = filepath.Join(dir, path)
+		configPath = filepath.Join(dir, configPath)
 	}
-	file, err := os.ReadFile(path)
+
+	file, err := os.ReadFile(configPath)
 	if err != nil {
 		if os.IsNotExist(err) {
-			return &args
+			return
 		}
-		fmt.Println("config file read failed, reason:" + err.Error())
-		return &args
+		return
 	}
-	if err = json.Unmarshal(file, &args); err != nil {
-		fmt.Println("config file parse failed, reason:" + err.Error())
-		return &args
+
+	if err = json.Unmarshal(file, cfg); err != nil {
+		return
+	}
+}
+
+// loadEnvVars loads configuration from environment variables with prefix GOPEED_
+func loadEnvVars(cfg *args) {
+	v := reflect.ValueOf(cfg).Elem()
+	t := reflect.TypeOf(cfg).Elem()
+
+	for i := 0; i < v.NumField(); i++ {
+		field := v.Field(i)
+		fieldType := t.Field(i)
+
+		// Get json tag as environment variable suffix
+		jsonTag := fieldType.Tag.Get("json")
+		if jsonTag == "" || jsonTag == "-" {
+			continue
+		}
+
+		// Remove options like omitempty
+		if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 {
+			jsonTag = jsonTag[:commaIdx]
+		}
+
+		// Convert to uppercase and add GOPEED_ prefix
+		envKey := "GOPEED_" + strings.ToUpper(jsonTag)
+		envValue := os.Getenv(envKey)
+
+		if envValue == "" {
+			continue
+		}
+
+		// Set value based on field type
+		if field.Kind() == reflect.Ptr {
+			if field.IsNil() {
+				// Create new pointer instance
+				newVal := reflect.New(field.Type().Elem())
+				field.Set(newVal)
+			}
+
+			switch field.Type().Elem().Kind() {
+			case reflect.String:
+				field.Elem().SetString(envValue)
+			case reflect.Int:
+				if intVal, err := strconv.Atoi(envValue); err == nil {
+					field.Elem().SetInt(int64(intVal))
+				}
+			default:
+				// For complex types like DownloadConfig, try JSON unmarshaling
+				if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {
+					var config base.DownloaderStoreConfig
+					if err := json.Unmarshal([]byte(envValue), &config); err == nil {
+						field.Set(reflect.ValueOf(&config))
+					}
+				}
+			}
+		} else if field.Kind() == reflect.Slice {
+			// Handle non-pointer slice types (like []string for WhiteDownloadDirs)
+			if field.Type().Elem().Kind() == reflect.String {
+				dirs := strings.Split(envValue, ",")
+				for i := range dirs {
+					dirs[i] = strings.TrimSpace(dirs[i])
+				}
+				field.Set(reflect.ValueOf(dirs))
+			}
+		}
 	}
-	fmt.Printf("config file loaded: %s\n", path)
-	return &args
 }
diff --git a/cmd/web/main.go b/cmd/web/main.go
index 32c2cb8..43273ef 100644
--- a/cmd/web/main.go
+++ b/cmd/web/main.go
@@ -6,11 +6,12 @@ package main
 import (
 	"embed"
 	"fmt"
-	"github.com/GopeedLab/gopeed/cmd"
-	"github.com/GopeedLab/gopeed/pkg/rest/model"
 	"io/fs"
 	"os"
 	"path/filepath"
+
+	"github.com/GopeedLab/gopeed/cmd"
+	"github.com/GopeedLab/gopeed/pkg/rest/model"
 )
 
 //go:embed dist/*
@@ -31,28 +32,29 @@ func main() {
 		}
 	}
 
-	var dir string
+	var storageDir string
 	if args.StorageDir != nil && *args.StorageDir != "" {
-		dir = *args.StorageDir
+		storageDir = *args.StorageDir
 	} else {
 		exe, err := os.Executable()
 		if err != nil {
 			panic(err)
 		}
-		dir = filepath.Dir(exe)
+		storageDir = filepath.Join(filepath.Dir(exe), "storage")
 	}
 
 	cfg := &model.StartConfig{
-		Network:        "tcp",
-		Address:        fmt.Sprintf("%s:%d", *args.Address, *args.Port),
-		Storage:        model.StorageBolt,
-		StorageDir:     filepath.Join(dir, "storage"),
-		ApiToken:       *args.ApiToken,
-		DownloadConfig: args.DownloadConfig,
-		ProductionMode: true,
-		WebEnable:      true,
-		WebFS:          sub,
-		WebAuth:        webAuth,
+		Network:           "tcp",
+		Address:           fmt.Sprintf("%s:%d", *args.Address, *args.Port),
+		Storage:           model.StorageBolt,
+		StorageDir:        storageDir,
+		WhiteDownloadDirs: args.WhiteDownloadDirs,
+		ApiToken:          *args.ApiToken,
+		DownloadConfig:    args.DownloadConfig,
+		ProductionMode:    true,
+		WebEnable:         true,
+		WebFS:             sub,
+		WebAuth:           webAuth,
 	}
 	cmd.Start(cfg)
 }
diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go
index 84cb5ec..35a6ed2 100644
--- a/pkg/download/downloader.go
+++ b/pkg/download/downloader.go
@@ -941,9 +941,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
 	}
 
 	// if enable white download directory, check if the download directory is in the white list
-	if len(d.cfg.DownloadDirWhiteList) > 0 {
+	if len(d.cfg.WhiteDownloadDirs) > 0 {
 		inWhiteList := false
-		for _, dir := range d.cfg.DownloadDirWhiteList {
+		for _, dir := range d.cfg.WhiteDownloadDirs {
 			if match, err := filepath.Match(dir, opts.Path); match && err == nil {
 				inWhiteList = true
 				break
diff --git a/pkg/download/model.go b/pkg/download/model.go
index 3da050c..6a7b024 100644
--- a/pkg/download/model.go
+++ b/pkg/download/model.go
@@ -137,10 +137,10 @@ type DownloaderConfig struct {
 	Controller    *controller.Controller
 	FetchManagers []fetcher.FetcherManager
 
-	RefreshInterval      int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
-	Storage              Storage
-	StorageDir           string
-	DownloadDirWhiteList []string `json:"downloadDirWhiteList"`
+	RefreshInterval   int // RefreshInterval time duration to refresh task progress(ms)
+	Storage           Storage
+	StorageDir        string
+	WhiteDownloadDirs []string
 
 	ProductionMode bool
 
diff --git a/pkg/rest/server.go b/pkg/rest/server.go
index 31c254a..80b49a1 100644
--- a/pkg/rest/server.go
+++ b/pkg/rest/server.go
@@ -83,8 +83,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
 	startCfg.Init()
 
 	downloadCfg := &download.DownloaderConfig{
-		ProductionMode:  startCfg.ProductionMode,
-		RefreshInterval: startCfg.RefreshInterval,
+		ProductionMode:    startCfg.ProductionMode,
+		RefreshInterval:   startCfg.RefreshInterval,
+		WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
 	}
 	if startCfg.Storage == model.StorageBolt {
 		downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)



Test patch:
diff --git a/cmd/web/flags_test.go b/cmd/web/flags_test.go
new file mode 100644
index 0000000..6642996
--- /dev/null
+++ b/cmd/web/flags_test.go
@@ -0,0 +1,698 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+
+	"github.com/GopeedLab/gopeed/pkg/base"
+)
+
+func TestSetDefaults(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    *args
+		expected *args
+	}{
+		{
+			name:  "empty config should get defaults",
+			input: &args{},
+			expected: &args{
+				Address:  stringPtr("0.0.0.0"),
+				Port:     intPtr(9999),
+				Username: stringPtr("gopeed"),
+			},
+		},
+		{
+			name: "partial config should only fill missing defaults",
+			input: &args{
+				Address: stringPtr("127.0.0.1"),
+			},
+			expected: &args{
+				Address:  stringPtr("127.0.0.1"),
+				Port:     intPtr(9999),
+				Username: stringPtr("gopeed"),
+			},
+		},
+		{
+			name: "full config should remain unchanged",
+			input: &args{
+				Address:  stringPtr("192.168.1.1"),
+				Port:     intPtr(8080),
+				Username: stringPtr("admin"),
+			},
+			expected: &args{
+				Address:  stringPtr("192.168.1.1"),
+				Port:     intPtr(8080),
+				Username: stringPtr("admin"),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			setDefaults(tt.input)
+			if !reflect.DeepEqual(tt.input, tt.expected) {
+				t.Errorf("setDefaults() = %+v, want %+v", tt.input, tt.expected)
+			}
+		})
+	}
+}
+
+func TestOverrideWithCliArgs(t *testing.T) {
+	tests := []struct {
+		name      string
+		config    *args
+		cliConfig *args
+		expected  *args
+	}{
+		{
+			name:   "empty cli should not override",
+			config: &args{Address: stringPtr("192.168.1.1")},
+			cliConfig: &args{
+				Address: stringPtr(""),
+				Port:    intPtr(0),
+			},
+			expected: &args{Address: stringPtr("192.168.1.1")},
+		},
+		{
+			name:   "non-empty cli should override",
+			config: &args{Address: stringPtr("192.168.1.1")},
+			cliConfig: &args{
+				Address: stringPtr("127.0.0.1"),
+				Port:    intPtr(8080),
+			},
+			expected: &args{
+				Address: stringPtr("127.0.0.1"),
+				Port:    intPtr(8080),
+			},
+		},
+		{
+			name: "all fields override test",
+			config: &args{
+				Address:    stringPtr("old_address"),
+				Port:       intPtr(1111),
+				Username:   stringPtr("old_user"),
+				Password:   stringPtr("old_pass"),
+				ApiToken:   stringPtr("old_token"),
+				StorageDir: stringPtr("old_dir"),
+			},
+			cliConfig: &args{
+				Address:    stringPtr("new_address"),
+				Port:       intPtr(2222),
+				Username:   stringPtr("new_user"),
+				Password:   stringPtr("new_pass"),
+				ApiToken:   stringPtr("new_token"),
+				StorageDir: stringPtr("new_dir"),
+			},
+			expected: &args{
+				Address:    stringPtr("new_address"),
+				Port:       intPtr(2222),
+				Username:   stringPtr("new_user"),
+				Password:   stringPtr("new_pass"),
+				ApiToken:   stringPtr("new_token"),
+				StorageDir: stringPtr("new_dir"),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			overrideWithCliArgs(tt.config, tt.cliConfig)
+			if !reflect.DeepEqual(tt.config, tt.expected) {
+				t.Errorf("overrideWithCliArgs() = %+v, want %+v", tt.config, tt.expected)
+			}
+		})
+	}
+}
+
+func TestLoadConfigFile(t *testing.T) {
+	// Create temporary directory for test files
+	tempDir, err := os.MkdirTemp("", "flags_test")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(tempDir)
+
+	tests := []struct {
+		name       string
+		configData string
+		fileName   string
+		expected   *args
+	}{
+		{
+			name: "valid config file",
+			configData: `{
+				"address": "192.168.1.100",
+				"port": 8080,
+				"username": "testuser",
+				"password": "testpass",
+				"apiToken": "testtoken",
+				"storageDir": "/test/storage",
+				"downloadConfig": {
+					"downloadDir": "/test/downloads",
+					"maxRunning": 10
+				}
+			}`,
+			fileName: "valid_config.json",
+			expected: &args{
+				Address:    stringPtr("192.168.1.100"),
+				Port:       intPtr(8080),
+				Username:   stringPtr("testuser"),
+				Password:   stringPtr("testpass"),
+				ApiToken:   stringPtr("testtoken"),
+				StorageDir: stringPtr("/test/storage"),
+				DownloadConfig: &base.DownloaderStoreConfig{
+					DownloadDir: "/test/downloads",
+					MaxRunning:  10,
+				},
+			},
+		},
+		{
+			name: "partial config file",
+			configData: `{
+				"address": "10.0.0.1",
+				"port": 3000
+			}`,
+			fileName: "partial_config.json",
+			expected: &args{
+				Address: stringPtr("10.0.0.1"),
+				Port:    intPtr(3000),
+			},
+		},
+		{
+			name:       "invalid json should not panic",
+			configData: `{invalid json}`,
+			fileName:   "invalid_config.json",
+			expected:   &args{},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create test config file
+			configPath := filepath.Join(tempDir, tt.fileName)
+			err := os.WriteFile(configPath, []byte(tt.configData), 0644)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			cfg := &args{}
+			loadConfigFile(cfg, configPath)
+
+			if !reflect.DeepEqual(cfg, tt.expected) {
+				t.Errorf("loadConfigFile() = %+v, want %+v", cfg, tt.expected)
+			}
+		})
+	}
+
+	// Test non-existent file
+	t.Run("non-existent file", func(t *testing.T) {
+		cfg := &args{}
+		loadConfigFile(cfg, "/non/existent/file.json")
+		expected := &args{}
+		if !reflect.DeepEqual(cfg, expected) {
+			t.Errorf("loadConfigFile() with non-existent file = %+v, want %+v", cfg, expected)
+		}
+	})
+}
+
+func TestLoadEnvVars(t *testing.T) {
+	// Save original environment
+	originalEnv := make(map[string]string)
+	envKeys := []string{
+		"GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME",
+		"GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_STORAGEDIR",
+		"GOPEED_DOWNLOADCONFIG", "GOPEED_WHITEDOWNLOADDIRS",
+	}
+	for _, key := range envKeys {
+		originalEnv[key] = os.Getenv(key)
+	}
+
+	// Clean up function
+	cleanup := func() {
+		for _, key := range envKeys {
+			if val, exists := originalEnv[key]; exists {
+				os.Setenv(key, val)
+			} else {
+				os.Unsetenv(key)
+			}
+		}
+	}
+	defer cleanup()
+
+	tests := []struct {
+		name     string
+		envVars  map[string]string
+		expected *args
+	}{
+		{
+			name: "all environment variables set",
+			envVars: map[string]string{
+				"GOPEED_ADDRESS":    "env.example.com",
+				"GOPEED_PORT":       "7777",
+				"GOPEED_USERNAME":   "envuser",
+				"GOPEED_PASSWORD":   "envpass",
+				"GOPEED_APITOKEN":   "envtoken",
+				"GOPEED_STORAGEDIR": "/env/storage",
+			},
+			expected: &args{
+				Address:    stringPtr("env.example.com"),
+				Port:       intPtr(7777),
+				Username:   stringPtr("envuser"),
+				Password:   stringPtr("envpass"),
+				ApiToken:   stringPtr("envtoken"),
+				StorageDir: stringPtr("/env/storage"),
+			},
+		},
+		{
+			name: "partial environment variables",
+			envVars: map[string]string{
+				"GOPEED_ADDRESS": "partial.example.com",
+				"GOPEED_PORT":    "5555",
+			},
+			expected: &args{
+				Address: stringPtr("partial.example.com"),
+				Port:    intPtr(5555),
+			},
+		},
+		{
+			name: "downloadConfig from environment",
+			envVars: map[string]string{
+				"GOPEED_DOWNLOADCONFIG": `{"downloadDir": "/env/downloads", "maxRunning": 15}`,
+			},
+			expected: &args{
+				DownloadConfig: &base.DownloaderStoreConfig{
+					DownloadDir: "/env/downloads",
+					MaxRunning:  15,
+				},
+			},
+		},
+		{
+			name: "invalid port should be ignored",
+			envVars: map[string]string{
+				"GOPEED_PORT": "invalid_port",
+			},
+			expected: &args{
+				Port: intPtr(0), // Invalid port creates pointer with 0 value
+			},
+		},
+		{
+			name: "invalid json for downloadConfig should be ignored",
+			envVars: map[string]string{
+				"GOPEED_DOWNLOADCONFIG": `{invalid json}`,
+			},
+			expected: &args{
+				DownloadConfig: &base.DownloaderStoreConfig{}, // Invalid JSON creates empty config
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Clear all environment variables first
+			for _, key := range envKeys {
+				os.Unsetenv(key)
+			}
+
+			// Set test environment variables
+			for key, value := range tt.envVars {
+				os.Setenv(key, value)
+			}
+
+			cfg := &args{}
+			loadEnvVars(cfg)
+
+			if !reflect.DeepEqual(cfg, tt.expected) {
+				t.Errorf("loadEnvVars() = %+v, want %+v", cfg, tt.expected)
+			}
+		})
+	}
+}
+
+func TestConfigPriority(t *testing.T) {
+	// Create temporary directory for test files
+	tempDir, err := os.MkdirTemp("", "flags_test_priority")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(tempDir)
+
+	// Create test config file
+	configData := `{
+		"address": "config.example.com",
+		"port": 6666,
+		"username": "configuser",
+		"password": "configpass"
+	}`
+	configPath := filepath.Join(tempDir, "test_config.json")
+	err = os.WriteFile(configPath, []byte(configData), 0644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Save original environment
+	originalEnv := make(map[string]string)
+	envKeys := []string{"GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME"}
+	for _, key := range envKeys {
+		originalEnv[key] = os.Getenv(key)
+	}
+	defer func() {
+		for _, key := range envKeys {
+			if val, exists := originalEnv[key]; exists {
+				os.Setenv(key, val)
+			} else {
+				os.Unsetenv(key)
+			}
+		}
+	}()
+
+	// Set environment variables
+	os.Setenv("GOPEED_ADDRESS", "env.example.com")
+	os.Setenv("GOPEED_PORT", "7777")
+
+	// Test configuration priority: CLI > ENV > Config File > Defaults
+	cfg := &args{}
+
+	// Load config file
+	loadConfigFile(cfg, configPath)
+
+	// Load environment variables (should override config file)
+	loadEnvVars(cfg)
+
+	// Simulate CLI args (should override env vars)
+	cliConfig := &args{
+		Address: stringPtr("cli.example.com"),
+		// Port not set in CLI, should remain from env
+		// Username not set in CLI or env, should remain from config file
+	}
+	overrideWithCliArgs(cfg, cliConfig)
+
+	// Set defaults for missing fields
+	setDefaults(cfg)
+
+	expected := &args{
+		Address:  stringPtr("cli.example.com"), // From CLI (highest priority)
+		Port:     intPtr(7777),                 // From ENV (overrides config file)
+		Username: stringPtr("configuser"),      // From config file (env not set)
+		Password: stringPtr("configpass"),      // From config file only
+	}
+
+	if !reflect.DeepEqual(cfg, expected) {
+		t.Errorf("Configuration priority test failed.\nGot: %+v\nWant: %+v", cfg, expected)
+	}
+}
+
+func TestCompleteConfigurationFlow(t *testing.T) {
+	// This test simulates a complete configuration loading scenario
+	// with all sources: defaults, config file, environment variables, and CLI args
+
+	tempDir, err := os.MkdirTemp("", "flags_test_complete")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer os.RemoveAll(tempDir)
+
+	// Create comprehensive config file
+	configData := `{
+		"address": "config.host.com",
+		"port": 5000,
+		"username": "configuser",
+		"password": "configpass",
+		"apiToken": "configtoken",
+		"storageDir": "/config/storage",
+		"downloadConfig": {
+			"downloadDir": "/config/downloads",
+			"maxRunning": 8,
+			"protocolConfig": {"http": {"maxConnections": 16}},
+			"proxy": {
+				"enable": true,
+				"system": false,
+				"scheme": "http",
+				"host": "proxy.example.com:8080"
+			}
+		}
+	}`
+	configPath := filepath.Join(tempDir, "complete_config.json")
+	err = os.WriteFile(configPath, []byte(configData), 0644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// Save and set environment variables
+	originalEnv := make(map[string]string)
+	envKeys := []string{"GOPEED_ADDRESS", "GOPEED_USERNAME", "GOPEED_APITOKEN", "GOPEED_WHITEDOWNLOADDIRS"}
+	for _, key := range envKeys {
+		originalEnv[key] = os.Getenv(key)
+	}
+	defer func() {
+		for _, key := range envKeys {
+			if val, exists := originalEnv[key]; exists {
+				os.Setenv(key, val)
+			} else {
+				os.Unsetenv(key)
+			}
+		}
+	}()
+
+	os.Setenv("GOPEED_ADDRESS", "env.host.com")
+	os.Setenv("GOPEED_USERNAME", "envuser")
+
+	// Simulate complete configuration loading
+	cfg := &args{}
+
+	// Load from config file
+	loadConfigFile(cfg, configPath)
+
+	// Override with environment variables
+	loadEnvVars(cfg)
+
+	// Override with CLI arguments
+	cliConfig := &args{
+		Port:       intPtr(9090),
+		StorageDir: stringPtr("/cli/storage"),
+	}
+	overrideWithCliArgs(cfg, cliConfig)
+
+	// Set defaults
+	setDefaults(cfg)
+
+	// Verify the final configuration follows priority rules
+	if *cfg.Address != "env.host.com" {
+		t.Errorf("Address should be from environment, got %s", *cfg.Address)
+	}
+	if *cfg.Port != 9090 {
+		t.Errorf("Port should be from CLI, got %d", *cfg.Port)
+	}
+	if *cfg.Username != "envuser" {
+		t.Errorf("Username should be from environment, got %s", *cfg.Username)
+	}
+	if *cfg.Password != "configpass" {
+		t.Errorf("Password should be from config file, got %s", *cfg.Password)
+	}
+	if *cfg.ApiToken != "configtoken" {
+		t.Errorf("ApiToken should be from config file, got %s", *cfg.ApiToken)
+	}
+	if *cfg.StorageDir != "/cli/storage" {
+		t.Errorf("StorageDir should be from CLI, got %s", *cfg.StorageDir)
+	}
+	if cfg.DownloadConfig == nil {
+		t.Error("DownloadConfig should be loaded from config file")
+	} else {
+		if cfg.DownloadConfig.DownloadDir != "/config/downloads" {
+			t.Errorf("DownloadConfig.DownloadDir should be from config file, got %s", cfg.DownloadConfig.DownloadDir)
+		}
+		if cfg.DownloadConfig.MaxRunning != 8 {
+			t.Errorf("DownloadConfig.MaxRunning should be from config file, got %d", cfg.DownloadConfig.MaxRunning)
+		}
+	}
+}
+
+// Helper functions for creating pointers
+func stringPtr(s string) *string {
+	return &s
+}
+
+func intPtr(i int) *int {
+	return &i
+}
+
+func TestWhiteDownloadDirs(t *testing.T) {
+	t.Run("overrideWithCliArgs should handle WhiteDownloadDirs", func(t *testing.T) {
+		tests := []struct {
+			name      string
+			config    *args
+			cliConfig *args
+			expected  *args
+		}{
+			{
+				name:   "nil cli WhiteDownloadDirs should not override",
+				config: &args{WhiteDownloadDirs: []string{"/old/dir1", "/old/dir2"}},
+				cliConfig: &args{
+					WhiteDownloadDirs: nil,
+				},
+				expected: &args{WhiteDownloadDirs: []string{"/old/dir1", "/old/dir2"}},
+			},
+			{
+				name:   "non-nil cli WhiteDownloadDirs should override",
+				config: &args{WhiteDownloadDirs: []string{"/old/dir1"}},
+				cliConfig: &args{
+					WhiteDownloadDirs: []string{"/new/dir1", "/new/dir2"},
+				},
+				expected: &args{WhiteDownloadDirs: []string{"/new/dir1", "/new/dir2"}},
+			},
+			{
+				name:   "empty cli WhiteDownloadDirs should override with empty slice",
+				config: &args{WhiteDownloadDirs: []string{"/old/dir1"}},
+				cliConfig: &args{
+					WhiteDownloadDirs: []string{},
+				},
+				expected: &args{WhiteDownloadDirs: []string{}},
+			},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				overrideWithCliArgs(tt.config, tt.cliConfig)
+				if !reflect.DeepEqual(tt.config, tt.expected) {
+					t.Errorf("overrideWithCliArgs() = %+v, want %+v", tt.config, tt.expected)
+				}
+			})
+		}
+	})
+
+	t.Run("loadConfigFile should handle WhiteDownloadDirs", func(t *testing.T) {
+		// Create temporary directory for test files
+		tempDir, err := os.MkdirTemp("", "flags_test_whitedirs")
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer os.RemoveAll(tempDir)
+
+		tests := []struct {
+			name       string
+			configData string
+			fileName   string
+			expected   *args
+		}{
+			{
+				name: "config with WhiteDownloadDirs array",
+				configData: `{
+					"address": "test.example.com",
+					"whiteDownloadDirs": ["/path/to/dir1", "/path/to/dir2", "/path/to/dir3"]
+				}`,
+				fileName: "whitedirs_config.json",
+				expected: &args{
+					Address:           stringPtr("test.example.com"),
+					WhiteDownloadDirs: []string{"/path/to/dir1", "/path/to/dir2", "/path/to/dir3"},
+				},
+			},
+			{
+				name: "config with empty WhiteDownloadDirs array",
+				configData: `{
+					"address": "test.example.com",
+					"whiteDownloadDirs": []
+				}`,
+				fileName: "empty_whitedirs_config.json",
+				expected: &args{
+					Address:           stringPtr("test.example.com"),
+					WhiteDownloadDirs: []string{},
+				},
+			},
+			{
+				name: "config without WhiteDownloadDirs",
+				configData: `{
+					"address": "test.example.com",
+					"port": 8080
+				}`,
+				fileName: "no_whitedirs_config.json",
+				expected: &args{
+					Address: stringPtr("test.example.com"),
+					Port:    intPtr(8080),
+				},
+			},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				// Create test config file
+				configPath := filepath.Join(tempDir, tt.fileName)
+				err := os.WriteFile(configPath, []byte(tt.configData), 0644)
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				cfg := &args{}
+				loadConfigFile(cfg, configPath)
+
+				if !reflect.DeepEqual(cfg, tt.expected) {
+					t.Errorf("loadConfigFile() = %+v, want %+v", cfg, tt.expected)
+				}
+			})
+		}
+	})
+
+	t.Run("loadEnvVars should handle WhiteDownloadDirs", func(t *testing.T) {
+		// Save original environment
+		originalEnv := os.Getenv("GOPEED_WHITEDOWNLOADDIRS")
+		defer func() {
+			if originalEnv != "" {
+				os.Setenv("GOPEED_WHITEDOWNLOADDIRS", originalEnv)
+			} else {
+				os.Unsetenv("GOPEED_WHITEDOWNLOADDIRS")
+			}
+		}()
+
+		tests := []struct {
+			name     string
+			envValue string
+			expected *args
+		}{
+			{
+				name:     "comma-separated directories",
+				envValue: "/env/dir1,/env/dir2,/env/dir3",
+				expected: &args{
+					WhiteDownloadDirs: []string{"/env/dir1", "/env/dir2", "/env/dir3"},
+				},
+			},
+			{
+				name:     "comma-separated with spaces",
+				envValue: " /env/dir1 , /env/dir2 , /env/dir3 ",
+				expected: &args{
+					WhiteDownloadDirs: []string{"/env/dir1", "/env/dir2", "/env/dir3"},
+				},
+			},
+			{
+				name:     "single directory",
+				envValue: "/single/dir",
+				expected: &args{
+					WhiteDownloadDirs: []string{"/single/dir"},
+				},
+			},
+			{
+				name:     "empty string should create empty slice",
+				envValue: "",
+				expected: &args{},
+			},
+		}
+
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				// Clear environment variable first
+				os.Unsetenv("GOPEED_WHITEDOWNLOADDIRS")
+
+				if tt.envValue != "" {
+					os.Setenv("GOPEED_WHITEDOWNLOADDIRS", tt.envValue)
+				}
+
+				cfg := &args{}
+				loadEnvVars(cfg)
+
+				if !reflect.DeepEqual(cfg, tt.expected) {
+					t.Errorf("loadEnvVars() = %+v, want %+v", cfg, tt.expected)
+				}
+			})
+		}
+	})
+}
diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go
index de0ffad..75b1b06 100644
--- a/pkg/download/downloader_test.go
+++ b/pkg/download/downloader_test.go
@@ -91,7 +91,7 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
 	defer listener.Close()
 
 	downloader := NewDownloader(&DownloaderConfig{
-		DownloadDirWhiteList: []string{"./downloads"},
+		WhiteDownloadDirs: []string{"./downloads"},
 	})
 	if err := downloader.Setup(); err != nil {
 		t.Fatal(err)