4
0
Эх сурвалжийг харах

Add optional config file parser in addition to environment variables

Frédéric Guillot 6 жил өмнө
parent
commit
f7b7b63e3f

+ 24 - 1
cli/cli.go

@@ -24,11 +24,14 @@ const (
 	flagResetPasswordHelp   = "Reset user password"
 	flagResetFeedErrorsHelp = "Clear all feed errors for all users"
 	flagDebugModeHelp       = "Show debug logs"
+	flagConfigFileHelp      = "Load configuration file"
+	flagConfigDumpHelp      = "Print parsed configuration values"
 )
 
 // Parse parses command line arguments.
 func Parse() {
 	var (
+		err                 error
 		flagInfo            bool
 		flagVersion         bool
 		flagMigrate         bool
@@ -37,6 +40,8 @@ func Parse() {
 		flagResetPassword   bool
 		flagResetFeedErrors bool
 		flagDebugMode       bool
+		flagConfigFile      string
+		flagConfigDump      bool
 	)
 
 	flag.BoolVar(&flagInfo, "info", false, flagInfoHelp)
@@ -49,12 +54,30 @@ func Parse() {
 	flag.BoolVar(&flagResetPassword, "reset-password", false, flagResetPasswordHelp)
 	flag.BoolVar(&flagResetFeedErrors, "reset-feed-errors", false, flagResetFeedErrorsHelp)
 	flag.BoolVar(&flagDebugMode, "debug", false, flagDebugModeHelp)
+	flag.StringVar(&flagConfigFile, "config-file", "", flagConfigFileHelp)
+	flag.StringVar(&flagConfigFile, "c", "", flagConfigFileHelp)
+	flag.BoolVar(&flagConfigDump, "config-dump", false, flagConfigDumpHelp)
 	flag.Parse()
 
-	if err := config.ParseConfig(); err != nil {
+	cfg := config.NewParser()
+
+	if flagConfigFile != "" {
+		config.Opts, err = cfg.ParseFile(flagConfigFile)
+		if err != nil {
+			logger.Fatal("%v", err)
+		}
+	}
+
+	config.Opts, err = cfg.ParseEnvironmentVariables()
+	if err != nil {
 		logger.Fatal("%v", err)
 	}
 
+	if flagConfigDump {
+		fmt.Print(config.Opts)
+		return
+	}
+
 	if flagDebugMode || config.Opts.HasDebugMode() {
 		logger.EnableDebug()
 	}

+ 1 - 7
config/config.go

@@ -4,11 +4,5 @@
 
 package config // import "miniflux.app/config"
 
-// Opts contains configuration options after parsing.
+// Opts holds parsed configuration options.
 var Opts *Options
-
-// ParseConfig parses configuration options.
-func ParseConfig() (err error) {
-	Opts, err = parse()
-	return err
-}

+ 244 - 127
config/config_test.go

@@ -5,6 +5,7 @@
 package config // import "miniflux.app/config"
 
 import (
+	"io/ioutil"
 	"os"
 	"testing"
 )
@@ -13,9 +14,10 @@ func TestDebugModeOn(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("DEBUG", "1")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	if !opts.HasDebugMode() {
@@ -26,9 +28,10 @@ func TestDebugModeOn(t *testing.T) {
 func TestDebugModeOff(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	if opts.HasDebugMode() {
@@ -40,9 +43,10 @@ func TestCustomBaseURL(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("BASE_URL", "http://example.org")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	if opts.BaseURL() != "http://example.org" {
@@ -62,9 +66,10 @@ func TestCustomBaseURLWithTrailingSlash(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("BASE_URL", "http://example.org/folder/")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	if opts.BaseURL() != "http://example.org/folder" {
@@ -84,7 +89,7 @@ func TestBaseURLWithoutScheme(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("BASE_URL", "example.org/folder/")
 
-	_, err := parse()
+	_, err := NewParser().ParseEnvironmentVariables()
 	if err == nil {
 		t.Fatalf(`Parsing must fail`)
 	}
@@ -94,7 +99,7 @@ func TestBaseURLWithInvalidScheme(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("BASE_URL", "ftp://example.org/folder/")
 
-	_, err := parse()
+	_, err := NewParser().ParseEnvironmentVariables()
 	if err == nil {
 		t.Fatalf(`Parsing must fail`)
 	}
@@ -104,7 +109,7 @@ func TestInvalidBaseURL(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("BASE_URL", "http://example|org")
 
-	_, err := parse()
+	_, err := NewParser().ParseEnvironmentVariables()
 	if err == nil {
 		t.Fatalf(`Parsing must fail`)
 	}
@@ -113,9 +118,10 @@ func TestInvalidBaseURL(t *testing.T) {
 func TestDefaultBaseURL(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	if opts.BaseURL() != defaultBaseURL {
@@ -135,41 +141,52 @@ func TestDatabaseURL(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("DATABASE_URL", "foobar")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "foobar"
 	result := opts.DatabaseURL()
 
 	if result != expected {
-		t.Fatalf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
+		t.Errorf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
+	}
+
+	if opts.IsDefaultDatabaseURL() {
+		t.Errorf(`This is not the default database URL and it should returns false`)
 	}
 }
 
 func TestDefaultDatabaseURLValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultDatabaseURL
 	result := opts.DatabaseURL()
 
 	if result != expected {
-		t.Fatalf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
+		t.Errorf(`Unexpected DATABASE_URL value, got %q instead of %q`, result, expected)
+	}
+
+	if !opts.IsDefaultDatabaseURL() {
+		t.Errorf(`This is the default database URL and it should returns true`)
 	}
 }
 
 func TestDefaultDatabaseMaxConnsValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultDatabaseMaxConns
@@ -184,9 +201,10 @@ func TestDatabaseMaxConns(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("DATABASE_MAX_CONNS", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 42
@@ -200,9 +218,10 @@ func TestDatabaseMaxConns(t *testing.T) {
 func TestDefaultDatabaseMinConnsValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultDatabaseMinConns
@@ -217,9 +236,10 @@ func TestDatabaseMinConns(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("DATABASE_MIN_CONNS", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 42
@@ -234,9 +254,10 @@ func TestListenAddr(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("LISTEN_ADDR", "foobar")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "foobar"
@@ -252,9 +273,10 @@ func TestListenAddrWithPortDefined(t *testing.T) {
 	os.Setenv("PORT", "3000")
 	os.Setenv("LISTEN_ADDR", "foobar")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := ":3000"
@@ -268,9 +290,10 @@ func TestListenAddrWithPortDefined(t *testing.T) {
 func TestDefaultListenAddrValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultListenAddr
@@ -285,9 +308,10 @@ func TestCertFile(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("CERT_FILE", "foobar")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "foobar"
@@ -301,9 +325,10 @@ func TestCertFile(t *testing.T) {
 func TestDefaultCertFileValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultCertFile
@@ -318,9 +343,10 @@ func TestKeyFile(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("KEY_FILE", "foobar")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "foobar"
@@ -334,9 +360,10 @@ func TestKeyFile(t *testing.T) {
 func TestDefaultKeyFileValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultKeyFile
@@ -351,9 +378,10 @@ func TestCertDomain(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("CERT_DOMAIN", "example.org")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "example.org"
@@ -367,9 +395,10 @@ func TestCertDomain(t *testing.T) {
 func TestDefaultCertDomainValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultCertDomain
@@ -384,9 +413,10 @@ func TestCertCache(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("CERT_CACHE", "foobar")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "foobar"
@@ -400,9 +430,10 @@ func TestCertCache(t *testing.T) {
 func TestDefaultCertCacheValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultCertCache
@@ -416,9 +447,10 @@ func TestDefaultCertCacheValue(t *testing.T) {
 func TestDefaultCleanupFrequencyValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultCleanupFrequency
@@ -433,9 +465,10 @@ func TestCleanupFrequency(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("CLEANUP_FREQUENCY", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 42
@@ -449,9 +482,10 @@ func TestCleanupFrequency(t *testing.T) {
 func TestDefaultWorkerPoolSizeValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultWorkerPoolSize
@@ -466,9 +500,10 @@ func TestWorkerPoolSize(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("WORKER_POOL_SIZE", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 42
@@ -482,9 +517,10 @@ func TestWorkerPoolSize(t *testing.T) {
 func TestDefautPollingFrequencyValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultPollingFrequency
@@ -499,9 +535,10 @@ func TestPollingFrequency(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("POLLING_FREQUENCY", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 42
@@ -515,9 +552,10 @@ func TestPollingFrequency(t *testing.T) {
 func TestDefaultBatchSizeValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultBatchSize
@@ -532,9 +570,10 @@ func TestBatchSize(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("BATCH_SIZE", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 42
@@ -548,9 +587,10 @@ func TestBatchSize(t *testing.T) {
 func TestOAuth2UserCreationWhenUnset(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := false
@@ -565,9 +605,10 @@ func TestOAuth2UserCreationAdmin(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("OAUTH2_USER_CREATION", "1")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := true
@@ -582,9 +623,10 @@ func TestOAuth2ClientID(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("OAUTH2_CLIENT_ID", "foobar")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "foobar"
@@ -598,9 +640,10 @@ func TestOAuth2ClientID(t *testing.T) {
 func TestDefaultOAuth2ClientIDValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultOAuth2ClientID
@@ -615,9 +658,10 @@ func TestOAuth2ClientSecret(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("OAUTH2_CLIENT_SECRET", "secret")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "secret"
@@ -631,9 +675,10 @@ func TestOAuth2ClientSecret(t *testing.T) {
 func TestDefaultOAuth2ClientSecretValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultOAuth2ClientSecret
@@ -648,9 +693,10 @@ func TestOAuth2RedirectURL(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("OAUTH2_REDIRECT_URL", "http://example.org")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "http://example.org"
@@ -664,9 +710,10 @@ func TestOAuth2RedirectURL(t *testing.T) {
 func TestDefaultOAuth2RedirectURLValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultOAuth2RedirectURL
@@ -681,9 +728,10 @@ func TestOAuth2Provider(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("OAUTH2_PROVIDER", "google")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "google"
@@ -697,9 +745,10 @@ func TestOAuth2Provider(t *testing.T) {
 func TestDefaultOAuth2ProviderValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultOAuth2Provider
@@ -713,9 +762,10 @@ func TestDefaultOAuth2ProviderValue(t *testing.T) {
 func TestHSTSWhenUnset(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := true
@@ -730,9 +780,10 @@ func TestHSTS(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("DISABLE_HSTS", "1")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := false
@@ -746,9 +797,10 @@ func TestHSTS(t *testing.T) {
 func TestDisableHTTPServiceWhenUnset(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := true
@@ -763,9 +815,10 @@ func TestDisableHTTPService(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("DISABLE_HTTP_SERVICE", "1")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := false
@@ -779,9 +832,10 @@ func TestDisableHTTPService(t *testing.T) {
 func TestDisableSchedulerServiceWhenUnset(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := true
@@ -796,9 +850,10 @@ func TestDisableSchedulerService(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("DISABLE_SCHEDULER_SERVICE", "1")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := false
@@ -813,9 +868,10 @@ func TestArchiveReadDays(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("ARCHIVE_READ_DAYS", "7")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 7
@@ -829,9 +885,10 @@ func TestArchiveReadDays(t *testing.T) {
 func TestRunMigrationsWhenUnset(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := false
@@ -846,9 +903,10 @@ func TestRunMigrations(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("RUN_MIGRATIONS", "yes")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := true
@@ -862,9 +920,10 @@ func TestRunMigrations(t *testing.T) {
 func TestCreateAdminWhenUnset(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := false
@@ -879,9 +938,10 @@ func TestCreateAdmin(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("CREATE_ADMIN", "true")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := true
@@ -896,9 +956,10 @@ func TestPocketConsumerKeyFromEnvVariable(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("POCKET_CONSUMER_KEY", "something")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "something"
@@ -912,9 +973,10 @@ func TestPocketConsumerKeyFromEnvVariable(t *testing.T) {
 func TestPocketConsumerKeyFromUserPrefs(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "default"
@@ -929,9 +991,10 @@ func TestProxyImages(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "all")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := "all"
@@ -945,9 +1008,10 @@ func TestProxyImages(t *testing.T) {
 func TestDefaultProxyImagesValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultProxyImages
@@ -961,9 +1025,10 @@ func TestDefaultProxyImagesValue(t *testing.T) {
 func TestHTTPSOff(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	if opts.HTTPS {
@@ -975,9 +1040,10 @@ func TestHTTPSOn(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("HTTPS", "on")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	if !opts.HTTPS {
@@ -989,9 +1055,10 @@ func TestHTTPClientTimeout(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("HTTP_CLIENT_TIMEOUT", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := 42
@@ -1005,9 +1072,10 @@ func TestHTTPClientTimeout(t *testing.T) {
 func TestDefaultHTTPClientTimeoutValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := defaultHTTPClientTimeout
@@ -1022,9 +1090,10 @@ func TestHTTPClientMaxBodySize(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("HTTP_CLIENT_MAX_BODY_SIZE", "42")
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := int64(42 * 1024 * 1024)
@@ -1038,9 +1107,10 @@ func TestHTTPClientMaxBodySize(t *testing.T) {
 func TestDefaultHTTPClientMaxBodySizeValue(t *testing.T) {
 	os.Clearenv()
 
-	opts, err := parse()
+	parser := NewParser()
+	opts, err := parser.ParseEnvironmentVariables()
 	if err != nil {
-		t.Fatalf(`Parsing failure: %q`, err)
+		t.Fatalf(`Parsing failure: %v`, err)
 	}
 
 	expected := int64(defaultHTTPClientMaxBodySize * 1024 * 1024)
@@ -1050,3 +1120,50 @@ func TestDefaultHTTPClientMaxBodySizeValue(t *testing.T) {
 		t.Fatalf(`Unexpected HTTP_CLIENT_MAX_BODY_SIZE value, got %d instead of %d`, result, expected)
 	}
 }
+
+func TestParseConfigFile(t *testing.T) {
+	content := []byte(`
+ # This is a comment
+
+DEBUG = yes
+
+ POCKET_CONSUMER_KEY= >#1234
+
+Invalid text
+`)
+
+	tmpfile, err := ioutil.TempFile(".", "miniflux.*.unit_test.conf")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := tmpfile.Write(content); err != nil {
+		t.Fatal(err)
+	}
+
+	os.Clearenv()
+
+	parser := NewParser()
+	opts, err := parser.ParseFile(tmpfile.Name())
+	if err != nil {
+		t.Errorf(`Parsing failure: %v`, err)
+	}
+
+	if opts.HasDebugMode() != true {
+		t.Errorf(`Unexpected debug mode value, got "%v"`, opts.HasDebugMode())
+	}
+
+	expected := ">#1234"
+	result := opts.PocketConsumerKey("default")
+	if result != expected {
+		t.Errorf(`Unexpected POCKET_CONSUMER_KEY value, got %q instead of %q`, result, expected)
+	}
+
+	if err := tmpfile.Close(); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := os.Remove(tmpfile.Name()); err != nil {
+		t.Fatal(err)
+	}
+}

+ 91 - 0
config/options.go

@@ -4,11 +4,24 @@
 
 package config // import "miniflux.app/config"
 
+import (
+	"fmt"
+	"strings"
+)
+
 const (
+	defaultHTTPS                 = false
+	defaultHSTS                  = true
+	defaultHTTPService           = true
+	defaultSchedulerService      = true
+	defaultDebug                 = false
 	defaultBaseURL               = "http://localhost"
+	defaultRootURL               = "http://localhost"
+	defaultBasePath              = ""
 	defaultWorkerPoolSize        = 5
 	defaultPollingFrequency      = 60
 	defaultBatchSize             = 10
+	defaultRunMigrations         = false
 	defaultDatabaseURL           = "user=postgres password=postgres dbname=miniflux2 sslmode=disable"
 	defaultDatabaseMaxConns      = 20
 	defaultDatabaseMinConns      = 1
@@ -20,10 +33,13 @@ const (
 	defaultCertCache             = "/tmp/cert_cache"
 	defaultCleanupFrequency      = 24
 	defaultProxyImages           = "http-only"
+	defaultCreateAdmin           = false
+	defaultOAuth2UserCreation    = false
 	defaultOAuth2ClientID        = ""
 	defaultOAuth2ClientSecret    = ""
 	defaultOAuth2RedirectURL     = ""
 	defaultOAuth2Provider        = ""
+	defaultPocketConsumerKey     = ""
 	defaultHTTPClientTimeout     = 20
 	defaultHTTPClientMaxBodySize = 15
 )
@@ -64,6 +80,44 @@ type Options struct {
 	httpClientMaxBodySize     int64
 }
 
+// NewOptions returns Options with default values.
+func NewOptions() *Options {
+	return &Options{
+		HTTPS:                     defaultHTTPS,
+		hsts:                      defaultHSTS,
+		httpService:               defaultHTTPService,
+		schedulerService:          defaultSchedulerService,
+		debug:                     defaultDebug,
+		baseURL:                   defaultBaseURL,
+		rootURL:                   defaultRootURL,
+		basePath:                  defaultBasePath,
+		databaseURL:               defaultDatabaseURL,
+		databaseMaxConns:          defaultDatabaseMaxConns,
+		databaseMinConns:          defaultDatabaseMinConns,
+		runMigrations:             defaultRunMigrations,
+		listenAddr:                defaultListenAddr,
+		certFile:                  defaultCertFile,
+		certDomain:                defaultCertDomain,
+		certCache:                 defaultCertCache,
+		certKeyFile:               defaultKeyFile,
+		cleanupFrequency:          defaultCleanupFrequency,
+		archiveReadDays:           defaultArchiveReadDays,
+		pollingFrequency:          defaultPollingFrequency,
+		batchSize:                 defaultBatchSize,
+		workerPoolSize:            defaultWorkerPoolSize,
+		createAdmin:               defaultCreateAdmin,
+		proxyImages:               defaultProxyImages,
+		oauth2UserCreationAllowed: defaultOAuth2UserCreation,
+		oauth2ClientID:            defaultOAuth2ClientID,
+		oauth2ClientSecret:        defaultOAuth2ClientSecret,
+		oauth2RedirectURL:         defaultOAuth2RedirectURL,
+		oauth2Provider:            defaultOAuth2Provider,
+		pocketConsumerKey:         defaultPocketConsumerKey,
+		httpClientTimeout:         defaultHTTPClientTimeout,
+		httpClientMaxBodySize:     defaultHTTPClientMaxBodySize * 1024 * 1024,
+	}
+}
+
 // HasDebugMode returns true if debug mode is enabled.
 func (o *Options) HasDebugMode() bool {
 	return o.debug
@@ -226,3 +280,40 @@ func (o *Options) HTTPClientTimeout() int {
 func (o *Options) HTTPClientMaxBodySize() int64 {
 	return o.httpClientMaxBodySize
 }
+
+func (o *Options) String() string {
+	var builder strings.Builder
+	builder.WriteString(fmt.Sprintf("DEBUG: %v\n", o.debug))
+	builder.WriteString(fmt.Sprintf("HTTP_SERVICE: %v\n", o.httpService))
+	builder.WriteString(fmt.Sprintf("SCHEDULER_SERVICE: %v\n", o.schedulerService))
+	builder.WriteString(fmt.Sprintf("HTTPS: %v\n", o.HTTPS))
+	builder.WriteString(fmt.Sprintf("HSTS: %v\n", o.hsts))
+	builder.WriteString(fmt.Sprintf("BASE_URL: %v\n", o.baseURL))
+	builder.WriteString(fmt.Sprintf("ROOT_URL: %v\n", o.rootURL))
+	builder.WriteString(fmt.Sprintf("BASE_PATH: %v\n", o.basePath))
+	builder.WriteString(fmt.Sprintf("LISTEN_ADDR: %v\n", o.listenAddr))
+	builder.WriteString(fmt.Sprintf("DATABASE_URL: %v\n", o.databaseURL))
+	builder.WriteString(fmt.Sprintf("DATABASE_MAX_CONNS: %v\n", o.databaseMaxConns))
+	builder.WriteString(fmt.Sprintf("DATABASE_MIN_CONNS: %v\n", o.databaseMinConns))
+	builder.WriteString(fmt.Sprintf("RUN_MIGRATIONS: %v\n", o.runMigrations))
+	builder.WriteString(fmt.Sprintf("CERT_FILE: %v\n", o.certFile))
+	builder.WriteString(fmt.Sprintf("KEY_FILE: %v\n", o.certKeyFile))
+	builder.WriteString(fmt.Sprintf("CERT_DOMAIN: %v\n", o.certDomain))
+	builder.WriteString(fmt.Sprintf("CERT_CACHE: %v\n", o.certCache))
+	builder.WriteString(fmt.Sprintf("CLEANUP_FREQUENCY: %v\n", o.cleanupFrequency))
+	builder.WriteString(fmt.Sprintf("WORKER_POOL_SIZE: %v\n", o.workerPoolSize))
+	builder.WriteString(fmt.Sprintf("POLLING_FREQUENCY: %v\n", o.pollingFrequency))
+	builder.WriteString(fmt.Sprintf("BATCH_SIZE: %v\n", o.batchSize))
+	builder.WriteString(fmt.Sprintf("ARCHIVE_READ_DAYS: %v\n", o.archiveReadDays))
+	builder.WriteString(fmt.Sprintf("PROXY_IMAGES: %v\n", o.proxyImages))
+	builder.WriteString(fmt.Sprintf("CREATE_ADMIN: %v\n", o.createAdmin))
+	builder.WriteString(fmt.Sprintf("POCKET_CONSUMER_KEY: %v\n", o.pocketConsumerKey))
+	builder.WriteString(fmt.Sprintf("OAUTH2_USER_CREATION: %v\n", o.oauth2UserCreationAllowed))
+	builder.WriteString(fmt.Sprintf("OAUTH2_CLIENT_ID: %v\n", o.oauth2ClientID))
+	builder.WriteString(fmt.Sprintf("OAUTH2_CLIENT_SECRET: %v\n", o.oauth2ClientSecret))
+	builder.WriteString(fmt.Sprintf("OAUTH2_REDIRECT_URL: %v\n", o.oauth2RedirectURL))
+	builder.WriteString(fmt.Sprintf("OAUTH2_PROVIDER: %v\n", o.oauth2Provider))
+	builder.WriteString(fmt.Sprintf("HTTP_CLIENT_TIMEOUT: %v\n", o.httpClientTimeout))
+	builder.WriteString(fmt.Sprintf("HTTP_CLIENT_MAX_BODY_SIZE: %v\n", o.httpClientMaxBodySize))
+	return builder.String()
+}

+ 146 - 68
config/parser.go

@@ -5,113 +5,184 @@
 package config // import "miniflux.app/config"
 
 import (
+	"bufio"
 	"errors"
 	"fmt"
-	"net/url"
+	"io"
+	url_parser "net/url"
 	"os"
 	"strconv"
 	"strings"
 )
 
-func parse() (opts *Options, err error) {
-	opts = &Options{}
-	opts.baseURL, opts.rootURL, opts.basePath, err = parseBaseURL()
+// Parser handles configuration parsing.
+type Parser struct {
+	opts *Options
+}
+
+// NewParser returns a new Parser.
+func NewParser() *Parser {
+	return &Parser{
+		opts: NewOptions(),
+	}
+}
+
+// ParseEnvironmentVariables loads configuration values from environment variables.
+func (p *Parser) ParseEnvironmentVariables() (*Options, error) {
+	err := p.parseLines(os.Environ())
 	if err != nil {
 		return nil, err
 	}
+	return p.opts, nil
+}
 
-	opts.debug = getBooleanValue("DEBUG")
-	opts.listenAddr = parseListenAddr()
-
-	opts.databaseURL = getStringValue("DATABASE_URL", defaultDatabaseURL)
-	opts.databaseMaxConns = getIntValue("DATABASE_MAX_CONNS", defaultDatabaseMaxConns)
-	opts.databaseMinConns = getIntValue("DATABASE_MIN_CONNS", defaultDatabaseMinConns)
-	opts.runMigrations = getBooleanValue("RUN_MIGRATIONS")
-
-	opts.hsts = !getBooleanValue("DISABLE_HSTS")
-	opts.HTTPS = getBooleanValue("HTTPS")
-
-	opts.schedulerService = !getBooleanValue("DISABLE_SCHEDULER_SERVICE")
-	opts.httpService = !getBooleanValue("DISABLE_HTTP_SERVICE")
-
-	opts.certFile = getStringValue("CERT_FILE", defaultCertFile)
-	opts.certKeyFile = getStringValue("KEY_FILE", defaultKeyFile)
-	opts.certDomain = getStringValue("CERT_DOMAIN", defaultCertDomain)
-	opts.certCache = getStringValue("CERT_CACHE", defaultCertCache)
+// ParseFile loads configuration values from a local file.
+func (p *Parser) ParseFile(filename string) (*Options, error) {
+	fp, err := os.Open(filename)
+	if err != nil {
+		return nil, err
+	}
+	defer fp.Close()
 
-	opts.cleanupFrequency = getIntValue("CLEANUP_FREQUENCY", defaultCleanupFrequency)
-	opts.workerPoolSize = getIntValue("WORKER_POOL_SIZE", defaultWorkerPoolSize)
-	opts.pollingFrequency = getIntValue("POLLING_FREQUENCY", defaultPollingFrequency)
-	opts.batchSize = getIntValue("BATCH_SIZE", defaultBatchSize)
-	opts.archiveReadDays = getIntValue("ARCHIVE_READ_DAYS", defaultArchiveReadDays)
-	opts.proxyImages = getStringValue("PROXY_IMAGES", defaultProxyImages)
-	opts.createAdmin = getBooleanValue("CREATE_ADMIN")
-	opts.pocketConsumerKey = getStringValue("POCKET_CONSUMER_KEY", "")
+	err = p.parseLines(p.parseFileContent(fp))
+	if err != nil {
+		return nil, err
+	}
+	return p.opts, nil
+}
 
-	opts.oauth2UserCreationAllowed = getBooleanValue("OAUTH2_USER_CREATION")
-	opts.oauth2ClientID = getStringValue("OAUTH2_CLIENT_ID", defaultOAuth2ClientID)
-	opts.oauth2ClientSecret = getStringValue("OAUTH2_CLIENT_SECRET", defaultOAuth2ClientSecret)
-	opts.oauth2RedirectURL = getStringValue("OAUTH2_REDIRECT_URL", defaultOAuth2RedirectURL)
-	opts.oauth2Provider = getStringValue("OAUTH2_PROVIDER", defaultOAuth2Provider)
+func (p *Parser) parseFileContent(r io.Reader) (lines []string) {
+	scanner := bufio.NewScanner(r)
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 {
+			lines = append(lines, line)
+		}
+	}
+	return lines
+}
 
-	opts.httpClientTimeout = getIntValue("HTTP_CLIENT_TIMEOUT", defaultHTTPClientTimeout)
-	opts.httpClientMaxBodySize = int64(getIntValue("HTTP_CLIENT_MAX_BODY_SIZE", defaultHTTPClientMaxBodySize) * 1024 * 1024)
+func (p *Parser) parseLines(lines []string) (err error) {
+	var port string
+
+	for _, line := range lines {
+		fields := strings.SplitN(line, "=", 2)
+		key := strings.TrimSpace(fields[0])
+		value := strings.TrimSpace(fields[1])
+
+		switch key {
+		case "DEBUG":
+			p.opts.debug = parseBool(value, defaultDebug)
+		case "BASE_URL":
+			p.opts.baseURL, p.opts.rootURL, p.opts.basePath, err = parseBaseURL(value)
+			if err != nil {
+				return err
+			}
+		case "PORT":
+			port = value
+		case "LISTEN_ADDR":
+			p.opts.listenAddr = parseString(value, defaultListenAddr)
+		case "DATABASE_URL":
+			p.opts.databaseURL = parseString(value, defaultDatabaseURL)
+		case "DATABASE_MAX_CONNS":
+			p.opts.databaseMaxConns = parseInt(value, defaultDatabaseMaxConns)
+		case "DATABASE_MIN_CONNS":
+			p.opts.databaseMinConns = parseInt(value, defaultDatabaseMinConns)
+		case "RUN_MIGRATIONS":
+			p.opts.runMigrations = parseBool(value, defaultRunMigrations)
+		case "DISABLE_HSTS":
+			p.opts.hsts = !parseBool(value, defaultHSTS)
+		case "HTTPS":
+			p.opts.HTTPS = parseBool(value, defaultHTTPS)
+		case "DISABLE_SCHEDULER_SERVICE":
+			p.opts.schedulerService = !parseBool(value, defaultSchedulerService)
+		case "DISABLE_HTTP_SERVICE":
+			p.opts.httpService = !parseBool(value, defaultHTTPService)
+		case "CERT_FILE":
+			p.opts.certFile = parseString(value, defaultCertFile)
+		case "KEY_FILE":
+			p.opts.certKeyFile = parseString(value, defaultKeyFile)
+		case "CERT_DOMAIN":
+			p.opts.certDomain = parseString(value, defaultCertDomain)
+		case "CERT_CACHE":
+			p.opts.certCache = parseString(value, defaultCertCache)
+		case "CLEANUP_FREQUENCY":
+			p.opts.cleanupFrequency = parseInt(value, defaultCleanupFrequency)
+		case "WORKER_POOL_SIZE":
+			p.opts.workerPoolSize = parseInt(value, defaultWorkerPoolSize)
+		case "POLLING_FREQUENCY":
+			p.opts.pollingFrequency = parseInt(value, defaultPollingFrequency)
+		case "BATCH_SIZE":
+			p.opts.batchSize = parseInt(value, defaultBatchSize)
+		case "ARCHIVE_READ_DAYS":
+			p.opts.archiveReadDays = parseInt(value, defaultArchiveReadDays)
+		case "PROXY_IMAGES":
+			p.opts.proxyImages = parseString(value, defaultProxyImages)
+		case "CREATE_ADMIN":
+			p.opts.createAdmin = parseBool(value, defaultCreateAdmin)
+		case "POCKET_CONSUMER_KEY":
+			p.opts.pocketConsumerKey = parseString(value, defaultPocketConsumerKey)
+		case "OAUTH2_USER_CREATION":
+			p.opts.oauth2UserCreationAllowed = parseBool(value, defaultOAuth2UserCreation)
+		case "OAUTH2_CLIENT_ID":
+			p.opts.oauth2ClientID = parseString(value, defaultOAuth2ClientID)
+		case "OAUTH2_CLIENT_SECRET":
+			p.opts.oauth2ClientSecret = parseString(value, defaultOAuth2ClientSecret)
+		case "OAUTH2_REDIRECT_URL":
+			p.opts.oauth2RedirectURL = parseString(value, defaultOAuth2RedirectURL)
+		case "OAUTH2_PROVIDER":
+			p.opts.oauth2Provider = parseString(value, defaultOAuth2Provider)
+		case "HTTP_CLIENT_TIMEOUT":
+			p.opts.httpClientTimeout = parseInt(value, defaultHTTPClientTimeout)
+		case "HTTP_CLIENT_MAX_BODY_SIZE":
+			p.opts.httpClientMaxBodySize = int64(parseInt(value, defaultHTTPClientMaxBodySize) * 1024 * 1024)
+		}
+	}
 
-	return opts, nil
+	if port != "" {
+		p.opts.listenAddr = ":" + port
+	}
+	return nil
 }
 
-func parseBaseURL() (string, string, string, error) {
-	baseURL := os.Getenv("BASE_URL")
-	if baseURL == "" {
-		return defaultBaseURL, defaultBaseURL, "", nil
+func parseBaseURL(value string) (string, string, string, error) {
+	if value == "" {
+		return defaultBaseURL, defaultRootURL, "", nil
 	}
 
-	if baseURL[len(baseURL)-1:] == "/" {
-		baseURL = baseURL[:len(baseURL)-1]
+	if value[len(value)-1:] == "/" {
+		value = value[:len(value)-1]
 	}
 
-	u, err := url.Parse(baseURL)
+	url, err := url_parser.Parse(value)
 	if err != nil {
 		return "", "", "", fmt.Errorf("Invalid BASE_URL: %v", err)
 	}
 
-	scheme := strings.ToLower(u.Scheme)
+	scheme := strings.ToLower(url.Scheme)
 	if scheme != "https" && scheme != "http" {
 		return "", "", "", errors.New("Invalid BASE_URL: scheme must be http or https")
 	}
 
-	basePath := u.Path
-	u.Path = ""
-	return baseURL, u.String(), basePath, nil
+	basePath := url.Path
+	url.Path = ""
+	return value, url.String(), basePath, nil
 }
 
-func parseListenAddr() string {
-	if port := os.Getenv("PORT"); port != "" {
-		return ":" + port
+func parseBool(value string, fallback bool) bool {
+	if value == "" {
+		return fallback
 	}
 
-	return getStringValue("LISTEN_ADDR", defaultListenAddr)
-}
-
-func getBooleanValue(key string) bool {
-	value := strings.ToLower(os.Getenv(key))
+	value = strings.ToLower(value)
 	if value == "1" || value == "yes" || value == "true" || value == "on" {
 		return true
 	}
-	return false
-}
-
-func getStringValue(key, fallback string) string {
-	value := os.Getenv(key)
-	if value == "" {
-		return fallback
-	}
 
-	return value
+	return false
 }
 
-func getIntValue(key string, fallback int) int {
-	value := os.Getenv(key)
+func parseInt(value string, fallback int) int {
 	if value == "" {
 		return fallback
 	}
@@ -123,3 +194,10 @@ func getIntValue(key string, fallback int) int {
 
 	return v
 }
+
+func parseString(value string, fallback string) string {
+	if value == "" {
+		return fallback
+	}
+	return value
+}

+ 13 - 31
config/parser_test.go

@@ -5,20 +5,12 @@
 package config // import "miniflux.app/config"
 
 import (
-	"os"
 	"testing"
 )
 
-func TestGetBooleanValueWithUnsetVariable(t *testing.T) {
-	os.Clearenv()
-	if getBooleanValue("MY_TEST_VARIABLE") {
-		t.Errorf(`Unset variables should returns false`)
-	}
-}
-
-func TestGetBooleanValue(t *testing.T) {
+func TestParseBoolValue(t *testing.T) {
 	scenarios := map[string]bool{
-		"":        false,
+		"":        true,
 		"1":       true,
 		"Yes":     true,
 		"yes":     true,
@@ -31,49 +23,39 @@ func TestGetBooleanValue(t *testing.T) {
 	}
 
 	for input, expected := range scenarios {
-		os.Clearenv()
-		os.Setenv("MY_TEST_VARIABLE", input)
-		result := getBooleanValue("MY_TEST_VARIABLE")
+		result := parseBool(input, true)
 		if result != expected {
 			t.Errorf(`Unexpected result for %q, got %v instead of %v`, input, result, expected)
 		}
 	}
 }
 
-func TestGetStringValueWithUnsetVariable(t *testing.T) {
-	os.Clearenv()
-	if getStringValue("MY_TEST_VARIABLE", "defaultValue") != "defaultValue" {
+func TestParseStringValueWithUnsetVariable(t *testing.T) {
+	if parseString("", "defaultValue") != "defaultValue" {
 		t.Errorf(`Unset variables should returns the default value`)
 	}
 }
 
-func TestGetStringValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MY_TEST_VARIABLE", "test")
-	if getStringValue("MY_TEST_VARIABLE", "defaultValue") != "test" {
+func TestParseStringValue(t *testing.T) {
+	if parseString("test", "defaultValue") != "test" {
 		t.Errorf(`Defined variables should returns the specified value`)
 	}
 }
 
-func TestGetIntValueWithUnsetVariable(t *testing.T) {
-	os.Clearenv()
-	if getIntValue("MY_TEST_VARIABLE", 42) != 42 {
+func TestParseIntValueWithUnsetVariable(t *testing.T) {
+	if parseInt("", 42) != 42 {
 		t.Errorf(`Unset variables should returns the default value`)
 	}
 }
 
-func TestGetIntValueWithInvalidInput(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MY_TEST_VARIABLE", "invalid integer")
-	if getIntValue("MY_TEST_VARIABLE", 42) != 42 {
+func TestParseIntValueWithInvalidInput(t *testing.T) {
+	if parseInt("invalid integer", 42) != 42 {
 		t.Errorf(`Invalid integer should returns the default value`)
 	}
 }
 
-func TestGetIntValue(t *testing.T) {
-	os.Clearenv()
-	os.Setenv("MY_TEST_VARIABLE", "2018")
-	if getIntValue("MY_TEST_VARIABLE", 42) != 2018 {
+func TestParseIntValue(t *testing.T) {
+	if parseInt("2018", 42) != 2018 {
 		t.Errorf(`Defined variables should returns the specified value`)
 	}
 }

+ 17 - 2
miniflux.1

@@ -5,14 +5,29 @@
 miniflux \- Minimalist and opinionated feed reader
 
 .SH SYNOPSIS
-\fBminiflux\fR [-vi] [-create-admin] [-debug] [-flush-sessions] [-info] [-migrate]
-         [-reset-feed-errors] [-reset-password] [-version]
+\fBminiflux\fR [-vic] [-create-admin] [-debug] [-flush-sessions] [-info] [-migrate]
+         [-reset-feed-errors] [-reset-password] [-version] [-config-file] [-config-dump]
 
 .SH DESCRIPTION
 \fBminiflux\fR is a minimalist and opinionated feed reader.
 
 .SH OPTIONS
 .PP
+.B \-c
+.RS 4
+Load configuration file\&.
+.RE
+.PP
+.B \-config-file
+.RS 4
+Load configuration file\&.
+.RE
+.PP
+.B \-config-dump
+.RS 4
+Print parsed configuration values\&.
+.RE
+.PP
 .B \-create-admin
 .RS 4
 Create admin user\&.

+ 56 - 8
template/functions_test.go

@@ -134,7 +134,13 @@ func TestElapsedTime(t *testing.T) {
 func TestProxyFilterWithHttpDefault(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "http-only")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
@@ -151,7 +157,13 @@ func TestProxyFilterWithHttpDefault(t *testing.T) {
 func TestProxyFilterWithHttpsDefault(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "http-only")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
@@ -168,7 +180,13 @@ func TestProxyFilterWithHttpsDefault(t *testing.T) {
 func TestProxyFilterWithHttpNever(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "none")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
@@ -185,7 +203,13 @@ func TestProxyFilterWithHttpNever(t *testing.T) {
 func TestProxyFilterWithHttpsNever(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "none")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
@@ -202,7 +226,13 @@ func TestProxyFilterWithHttpsNever(t *testing.T) {
 func TestProxyFilterWithHttpAlways(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "all")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
@@ -219,7 +249,13 @@ func TestProxyFilterWithHttpAlways(t *testing.T) {
 func TestProxyFilterWithHttpsAlways(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "all")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
@@ -236,7 +272,13 @@ func TestProxyFilterWithHttpsAlways(t *testing.T) {
 func TestProxyFilterWithHttpInvalid(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "invalid")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")
@@ -253,7 +295,13 @@ func TestProxyFilterWithHttpInvalid(t *testing.T) {
 func TestProxyFilterWithHttpsInvalid(t *testing.T) {
 	os.Clearenv()
 	os.Setenv("PROXY_IMAGES", "invalid")
-	config.ParseConfig()
+
+	var err error
+	parser := config.NewParser()
+	config.Opts, err = parser.ParseEnvironmentVariables()
+	if err != nil {
+		t.Fatalf(`Parsing failure: %v`, err)
+	}
 
 	r := mux.NewRouter()
 	r.HandleFunc("/proxy/{encodedURL}", func(w http.ResponseWriter, r *http.Request) {}).Name("proxy")