diff --git a/.gitignore b/.gitignore index e098088..9ba87d3 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ tests.xml .vscode .DS_Store .history +.env diff --git a/README.md b/README.md index 88ae8b5..e296dd1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ _Please note: you will need Docker installed on your local machine._ The application uses environment variables to configure all aspects. +You can optionally supply these through a `.env` file that will be parsed before +any additional environment variables. + ### General Configuration * `J_ARTICLES_PER_PAGE` - Articles to display per page, default `20` diff --git a/internal/app/app.go b/internal/app/app.go index afc543d..acff1d5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,7 @@ import ( "strconv" "github.com/jamiefdhurst/journal/pkg/database/rows" + "github.com/jamiefdhurst/journal/pkg/env" ) // Database Define same interface as database @@ -84,40 +85,53 @@ func DefaultConfiguration() Configuration { } // ApplyEnvConfiguration applies the env variables on top of existing config +// It first loads values from a .env file (if it exists), then applies any +// environment variables set in the system (which override .env values) func ApplyEnvConfiguration(config *Configuration) { - articles, _ := strconv.Atoi(os.Getenv("J_ARTICLES_PER_PAGE")) + // Parse .env file (if it exists) + dotenvVars, _ := env.Parse(".env") + + // Helper function to get env var, preferring system env over .env file + getEnv := func(key string) string { + if val := os.Getenv(key); val != "" { + return val + } + return dotenvVars[key] + } + + articles, _ := strconv.Atoi(getEnv("J_ARTICLES_PER_PAGE")) if articles > 0 { config.ArticlesPerPage = articles } - database := os.Getenv("J_DB_PATH") + database := getEnv("J_DB_PATH") if database != "" { config.DatabasePath = database } - description := os.Getenv("J_DESCRIPTION") + description := getEnv("J_DESCRIPTION") if description != "" { config.Description = description } - enableCreate := os.Getenv("J_CREATE") + enableCreate := getEnv("J_CREATE") if enableCreate == "0" { config.EnableCreate = false } - enableEdit := os.Getenv("J_EDIT") + enableEdit := getEnv("J_EDIT") if enableEdit == "0" { config.EnableEdit = false } - excerptWords, _ := strconv.Atoi(os.Getenv("J_EXCERPT_WORDS")) + excerptWords, _ := strconv.Atoi(getEnv("J_EXCERPT_WORDS")) if excerptWords > 0 { config.ExcerptWords = excerptWords } - config.GoogleAnalyticsCode = os.Getenv("J_GA_CODE") - port := os.Getenv("J_PORT") + config.GoogleAnalyticsCode = getEnv("J_GA_CODE") + port := getEnv("J_PORT") if port != "" { config.Port = port } - config.SSLCertificate = os.Getenv("J_SSL_CERT") - config.SSLKey = os.Getenv("J_SSL_KEY") + config.SSLCertificate = getEnv("J_SSL_CERT") + config.SSLKey = getEnv("J_SSL_KEY") - sessionKey := os.Getenv("J_SESSION_KEY") + sessionKey := getEnv("J_SESSION_KEY") if sessionKey != "" { if len(sessionKey) != 32 { log.Println("WARNING: J_SESSION_KEY must be exactly 32 bytes. Using auto-generated key instead.") @@ -133,22 +147,22 @@ func ApplyEnvConfiguration(config *Configuration) { } config.SessionKey = sessionKey - sessionName := os.Getenv("J_SESSION_NAME") + sessionName := getEnv("J_SESSION_NAME") if sessionName != "" { config.SessionName = sessionName } - cookieDomain := os.Getenv("J_COOKIE_DOMAIN") + cookieDomain := getEnv("J_COOKIE_DOMAIN") if cookieDomain != "" { config.CookieDomain = cookieDomain } - cookieMaxAge, _ := strconv.Atoi(os.Getenv("J_COOKIE_MAX_AGE")) + cookieMaxAge, _ := strconv.Atoi(getEnv("J_COOKIE_MAX_AGE")) if cookieMaxAge > 0 { config.CookieMaxAge = cookieMaxAge } - cookieHTTPOnly := os.Getenv("J_COOKIE_HTTPONLY") + cookieHTTPOnly := getEnv("J_COOKIE_HTTPONLY") if cookieHTTPOnly == "0" || cookieHTTPOnly == "false" { config.CookieHTTPOnly = false } @@ -157,19 +171,19 @@ func ApplyEnvConfiguration(config *Configuration) { config.CookieSecure = true } - staticPath := os.Getenv("J_STATIC_PATH") + staticPath := getEnv("J_STATIC_PATH") if staticPath != "" { config.StaticPath = staticPath } - theme := os.Getenv("J_THEME") + theme := getEnv("J_THEME") if theme != "" { config.Theme = theme } - themePath := os.Getenv("J_THEME_PATH") + themePath := getEnv("J_THEME_PATH") if themePath != "" { config.ThemePath = themePath } - title := os.Getenv("J_TITLE") + title := getEnv("J_TITLE") if title != "" { config.Title = title } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index bccce5f..b0835ac 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -2,6 +2,7 @@ package app import ( "os" + "path/filepath" "testing" ) @@ -355,3 +356,102 @@ func TestApplyEnvConfiguration_Combined(t *testing.T) { t.Errorf("Expected Port '8080', got %q", config.Port) } } + +func TestApplyEnvConfiguration_DotEnvFile(t *testing.T) { + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Create a .env file + envContent := `J_PORT=9000 +J_TITLE=Test Journal +J_DESCRIPTION=A test journal +J_ARTICLES_PER_PAGE=15 +J_COOKIE_MAX_AGE=3600 +` + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + if config.Port != "9000" { + t.Errorf("Expected Port '9000' from .env, got %q", config.Port) + } + if config.Title != "Test Journal" { + t.Errorf("Expected Title 'Test Journal' from .env, got %q", config.Title) + } + if config.Description != "A test journal" { + t.Errorf("Expected Description 'A test journal' from .env, got %q", config.Description) + } + if config.ArticlesPerPage != 15 { + t.Errorf("Expected ArticlesPerPage 15 from .env, got %d", config.ArticlesPerPage) + } + if config.CookieMaxAge != 3600 { + t.Errorf("Expected CookieMaxAge 3600 from .env, got %d", config.CookieMaxAge) + } +} + +func TestApplyEnvConfiguration_EnvOverridesDotEnv(t *testing.T) { + // Save current working directory and environment + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + defer os.Unsetenv("J_PORT") + defer os.Unsetenv("J_TITLE") + + // Create a temporary directory for testing + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Create a .env file + envContent := `J_PORT=9000 +J_TITLE=DotEnv Title +J_DESCRIPTION=DotEnv Description +` + if err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644); err != nil { + t.Fatalf("Failed to create .env file: %v", err) + } + + // Set environment variables that should override .env + os.Setenv("J_PORT", "7777") + os.Setenv("J_TITLE", "Override Title") + + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + // Environment variables should override .env values + if config.Port != "7777" { + t.Errorf("Expected Port '7777' from env var (not .env), got %q", config.Port) + } + if config.Title != "Override Title" { + t.Errorf("Expected Title 'Override Title' from env var (not .env), got %q", config.Title) + } + // Values not overridden should come from .env + if config.Description != "DotEnv Description" { + t.Errorf("Expected Description 'DotEnv Description' from .env, got %q", config.Description) + } +} + +func TestApplyEnvConfiguration_NoDotEnvFile(t *testing.T) { + // Save current working directory + originalWd, _ := os.Getwd() + defer os.Chdir(originalWd) + + // Create a temporary directory without .env file + tmpDir := t.TempDir() + os.Chdir(tmpDir) + + // Should work fine even without .env file + config := DefaultConfiguration() + ApplyEnvConfiguration(&config) + + // Should have default values + if config.Port != "3000" { + t.Errorf("Expected default Port '3000', got %q", config.Port) + } +} diff --git a/pkg/env/parser.go b/pkg/env/parser.go new file mode 100644 index 0000000..8ed3c7e --- /dev/null +++ b/pkg/env/parser.go @@ -0,0 +1,63 @@ +package env + +import ( + "bufio" + "os" + "strings" +) + +// Parse reads a .env file and returns a map of key-value pairs +// It does not modify the actual environment variables +func Parse(filepath string) (map[string]string, error) { + result := make(map[string]string) + + file, err := os.Open(filepath) + if err != nil { + // If file doesn't exist, return empty map (not an error) + if os.IsNotExist(err) { + return result, nil + } + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Split on first = sign + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + // Remove quotes if present + value = unquote(value) + + result[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return result, nil +} + +// unquote removes surrounding quotes from a string +func unquote(s string) string { + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/pkg/env/parser_test.go b/pkg/env/parser_test.go new file mode 100644 index 0000000..1185acd --- /dev/null +++ b/pkg/env/parser_test.go @@ -0,0 +1,177 @@ +package env + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + content string + expected map[string]string + }{ + { + name: "basic key-value pairs", + content: `KEY1=value1 +KEY2=value2 +KEY3=value3`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "value3", + }, + }, + { + name: "with comments", + content: `# This is a comment +KEY1=value1 +# Another comment +KEY2=value2`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "with empty lines", + content: `KEY1=value1 + +KEY2=value2 + +`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "with quoted values", + content: `KEY1="value with spaces" +KEY2='single quoted value' +KEY3=unquoted`, + expected: map[string]string{ + "KEY1": "value with spaces", + "KEY2": "single quoted value", + "KEY3": "unquoted", + }, + }, + { + name: "with spaces around equals", + content: `KEY1 = value1 +KEY2= value2 +KEY3 =value3`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + "KEY3": "value3", + }, + }, + { + name: "with equals in value", + content: `KEY1=value=with=equals +KEY2=http://example.com?param=value`, + expected: map[string]string{ + "KEY1": "value=with=equals", + "KEY2": "http://example.com?param=value", + }, + }, + { + name: "malformed lines are skipped", + content: `KEY1=value1 +INVALID_LINE_NO_EQUALS +KEY2=value2`, + expected: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + }, + { + name: "empty file", + content: "", + expected: map[string]string{}, + }, + { + name: "only comments and empty lines", + content: `# Comment 1 +# Comment 2 + +# Comment 3`, + expected: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a temporary .env file + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + + if err := os.WriteFile(envFile, []byte(tt.content), 0644); err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + // Parse the file + result, err := Parse(envFile) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Check the results + if len(result) != len(tt.expected) { + t.Errorf("Expected %d entries, got %d", len(tt.expected), len(result)) + } + + for key, expectedValue := range tt.expected { + if actualValue, ok := result[key]; !ok { + t.Errorf("Missing key %q", key) + } else if actualValue != expectedValue { + t.Errorf("For key %q: expected %q, got %q", key, expectedValue, actualValue) + } + } + + for key := range result { + if _, ok := tt.expected[key]; !ok { + t.Errorf("Unexpected key %q with value %q", key, result[key]) + } + } + }) + } +} + +func TestParseNonExistentFile(t *testing.T) { + // Parsing a non-existent file should return an empty map, not an error + result, err := Parse("/nonexistent/path/.env") + if err != nil { + t.Errorf("Parse() should not error on non-existent file, got: %v", err) + } + if len(result) != 0 { + t.Errorf("Expected empty map, got %d entries", len(result)) + } +} + +func TestUnquote(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`"double quoted"`, "double quoted"}, + {`'single quoted'`, "single quoted"}, + {`unquoted`, "unquoted"}, + {`"`, `"`}, + {`''`, ``}, + {`""`, ``}, + {`"mismatched'`, `"mismatched'`}, + {`'mismatched"`, `'mismatched"`}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := unquote(tt.input) + if result != tt.expected { + t.Errorf("unquote(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +}