Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ tests.xml
.vscode
.DS_Store
.history
.env
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
52 changes: 33 additions & 19 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
100 changes: 100 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package app

import (
"os"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -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)
}
}
63 changes: 63 additions & 0 deletions pkg/env/parser.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading