From 4193ed4f9ed86d09bdca03dac0992b0f6bd20f34 Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 00:02:32 +0100 Subject: [PATCH 1/9] feat: replace singleton appstate approach with manual dep injection --- cmd/expira/main.go | 21 ++++--- internal/api/routes.go | 32 +++++----- internal/cron/cron.go | 10 ++-- internal/state/AppState.go | 27 --------- internal/state/Config.go | 24 ++++++++ internal/state/DomainStore.go | 70 ++++++++++++++++++++++ internal/utils/cli_report.go | 7 ++- internal/utils/env_import.go | 87 +++++++++++++++------------- internal/utils/notifications.go | 22 ++++--- internal/utils/providers/discord.go | 6 +- internal/utils/providers/ntfy.go | 6 +- internal/utils/providers/telegram.go | 8 ++- internal/utils/whois.go | 15 +++-- 13 files changed, 218 insertions(+), 117 deletions(-) delete mode 100644 internal/state/AppState.go create mode 100644 internal/state/Config.go create mode 100644 internal/state/DomainStore.go diff --git a/cmd/expira/main.go b/cmd/expira/main.go index c1f818e..a4d5810 100644 --- a/cmd/expira/main.go +++ b/cmd/expira/main.go @@ -7,7 +7,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/iwa/Expira/internal/api" "github.com/iwa/Expira/internal/cron" - "github.com/iwa/Expira/internal/state" "github.com/iwa/Expira/internal/utils" ) @@ -25,23 +24,27 @@ var titleStyle = lipgloss.NewStyle(). func main() { fmt.Println(titleStyle.Render("Domain Expiry Watcher")) - appState := state.GetInstance() - - utils.ImportEnv(appState) + // Load configuration and initialize domain store using dependency injection + config, store := utils.LoadConfig() println("[INFO] Starting domain expiry watcher...") - utils.UpdateDomains(appState) + // Update domain expiry dates from WHOIS servers + utils.UpdateDomains(store) - utils.ReportStatusInConsole(appState) + // Display domain status in console + utils.ReportStatusInConsole(store) - utils.Notify(appState) + // Send initial notifications if needed + utils.Notify(store, config) + // Setup HTTP API endpoints with dependency injection http.HandleFunc("/health", api.HealthHandler) - http.HandleFunc("/status", api.StatusHandler) + http.HandleFunc("/status", api.StatusHandlerFactory(store)) go http.ListenAndServe("0.0.0.0:8080", nil) - cron.StartCronLoop(appState) + // Start cron job for daily domain updates + cron.StartCronLoop(store, config) select {} // Keep the main goroutine running } diff --git a/internal/api/routes.go b/internal/api/routes.go index b45cb7c..aa6d25c 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -15,24 +15,26 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Service is running") } -// GET /status -// Get a status report of all monitored domains and their expiry dates. -func StatusHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - return - } +// StatusHandlerFactory creates a status handler with access to the domain store. +// This allows the handler to use dependency injection instead of global state. +func StatusHandlerFactory(store *state.DomainStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + return + } - var status string = "Domain Expiry Watcher Status:\n\n" + var status string = "Domain Expiry Watcher Status:\n\n" - appState := state.GetInstance() + domains := store.GetAllDomains() - for _, domain := range appState.Domains { - if domain.ExpiryDate.IsZero() { - status += fmt.Sprintf("Domain %s: Expiry date not set\n", domain.Name) - } else { - status += fmt.Sprintf("Domain %s: Expires on %s\n", domain.Name, domain.ExpiryDate.Format("2006-01-02")) + for _, domain := range domains { + if domain.ExpiryDate.IsZero() { + status += fmt.Sprintf("Domain %s: Expiry date not set\n", domain.Name) + } else { + status += fmt.Sprintf("Domain %s: Expires on %s\n", domain.Name, domain.ExpiryDate.Format("2006-01-02")) + } } - } - fmt.Fprintf(w, "%s", status) + fmt.Fprintf(w, "%s", status) + } } diff --git a/internal/cron/cron.go b/internal/cron/cron.go index 300d57d..aadc1e0 100644 --- a/internal/cron/cron.go +++ b/internal/cron/cron.go @@ -7,7 +7,9 @@ import ( "github.com/iwa/Expira/internal/utils" ) -func StartCronLoop(appState *state.AppState) { +// StartCronLoop starts a hourly cron job that runs domain updates at midnight. +// It uses dependency injection to access the domain store and configuration. +func StartCronLoop(store *state.DomainStore, config *state.Config) { println("[INFO] Starting cron job...") ticker := time.NewTicker(time.Hour) @@ -22,11 +24,11 @@ func StartCronLoop(appState *state.AppState) { if checkMidnight(t) { println("[INFO] Daily domains refresh cron job triggered at", t.Format("2006-01-02 15:04:05")) - utils.UpdateDomains(appState) + utils.UpdateDomains(store) - utils.Notify(appState) + utils.Notify(store, config) - utils.ReportStatusInConsole(appState) + utils.ReportStatusInConsole(store) } } } diff --git a/internal/state/AppState.go b/internal/state/AppState.go deleted file mode 100644 index 7b9efc6..0000000 --- a/internal/state/AppState.go +++ /dev/null @@ -1,27 +0,0 @@ -package state - -type AppState struct { - Domains map[string]Domain - NotificationDays []int - - TelegramNotification bool - TelegramChatID string - TelegramToken string - - DiscordNotification bool - DiscordWebhookURL string - - NtfyNotification bool - NtfyURL string -} - -var instance AppState - -// Initialize the singleton instance when the package is loaded -func init() { - instance = AppState{} -} - -func GetInstance() *AppState { - return &instance -} diff --git a/internal/state/Config.go b/internal/state/Config.go new file mode 100644 index 0000000..01a0717 --- /dev/null +++ b/internal/state/Config.go @@ -0,0 +1,24 @@ +package state + +// Config holds immutable configuration loaded from environment variables at startup. +// This configuration does not change during the application lifecycle. +type Config struct { + NotificationDays []int + + TelegramNotification bool + TelegramChatID string + TelegramToken string + + DiscordNotification bool + DiscordWebhookURL string + + NtfyNotification bool + NtfyURL string +} + +// NewConfig creates a new Config instance with default values +func NewConfig() *Config { + return &Config{ + NotificationDays: []int{}, + } +} diff --git a/internal/state/DomainStore.go b/internal/state/DomainStore.go new file mode 100644 index 0000000..d2ab7f9 --- /dev/null +++ b/internal/state/DomainStore.go @@ -0,0 +1,70 @@ +package state + +import ( + "sync" +) + +// DomainStore provides thread-safe access to domain data. +// It uses a read-write mutex to allow concurrent reads while ensuring safe writes. +type DomainStore struct { + mu sync.RWMutex + domains map[string]Domain +} + +// NewDomainStore creates a new DomainStore instance +func NewDomainStore() *DomainStore { + return &DomainStore{ + domains: make(map[string]Domain), + } +} + +// GetDomain retrieves a domain by name. +// Returns the domain and true if found, or an empty domain and false if not found. +func (ds *DomainStore) GetDomain(name string) (Domain, bool) { + ds.mu.RLock() + defer ds.mu.RUnlock() + + domain, ok := ds.domains[name] + return domain, ok +} + +// SetDomain stores or updates a domain. +// This method is thread-safe and can be called from multiple goroutines. +func (ds *DomainStore) SetDomain(name string, domain Domain) { + ds.mu.Lock() + defer ds.mu.Unlock() + + ds.domains[name] = domain +} + +// GetAllDomains returns a copy of all domains. +// This ensures the returned map cannot cause race conditions if modified by the caller. +func (ds *DomainStore) GetAllDomains() map[string]Domain { + ds.mu.RLock() + defer ds.mu.RUnlock() + + // Create a copy to avoid exposing internal map + domainsCopy := make(map[string]Domain, len(ds.domains)) + for k, v := range ds.domains { + domainsCopy[k] = v + } + + return domainsCopy +} + +// SetDomains replaces all domains with the provided map. +// This is useful for bulk initialization. +func (ds *DomainStore) SetDomains(domains map[string]Domain) { + ds.mu.Lock() + defer ds.mu.Unlock() + + ds.domains = domains +} + +// Count returns the number of domains in the store +func (ds *DomainStore) Count() int { + ds.mu.RLock() + defer ds.mu.RUnlock() + + return len(ds.domains) +} diff --git a/internal/utils/cli_report.go b/internal/utils/cli_report.go index 7580fcc..10674bd 100644 --- a/internal/utils/cli_report.go +++ b/internal/utils/cli_report.go @@ -6,14 +6,17 @@ import ( "github.com/iwa/Expira/internal/state" ) -func ReportStatusInConsole(appState *state.AppState) { +// ReportStatusInConsole displays the current status of all domains in the console. +// It uses the provided store to read domain data. +func ReportStatusInConsole(store *state.DomainStore) { println("[INFO] Generating domains report...") println("\n --- Current Domains Status ---") currentTime := time.Now() - for domain, domainData := range appState.Domains { + domains := store.GetAllDomains() + for domain, domainData := range domains { daysLeft := int(domainData.ExpiryDate.Sub(currentTime).Hours()/24) + 1 println("Domain:", domain, "- In", daysLeft, "Days - Expiry date:", domainData.ExpiryDate.Format("2006-01-02 15:04:05")) } diff --git a/internal/utils/env_import.go b/internal/utils/env_import.go index e8a10a5..d6de8cb 100644 --- a/internal/utils/env_import.go +++ b/internal/utils/env_import.go @@ -13,7 +13,12 @@ import ( "github.com/iwa/Expira/internal/state" ) -func ImportEnv(appState *state.AppState) { +// LoadConfig loads configuration from environment variables. +// Returns a Config instance and a DomainStore initialized with domains from env. +func LoadConfig() (*state.Config, *state.DomainStore) { + config := state.NewConfig() + store := state.NewDomainStore() + t := table.New(). Border(lipgloss.RoundedBorder()). BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4"))). @@ -27,16 +32,18 @@ func ImportEnv(appState *state.AppState) { }). Headers(" Configuration from environment variables ") - t.Row(importDomains(appState)) - t.Row(importNotificationDaysConfig(appState)) - importTelegramConfig(appState) - importDiscordConfig(appState) - importNtfyConfig(appState) + t.Row(importDomains(store)) + t.Row(importNotificationDaysConfig(config)) + importTelegramConfig(config) + importDiscordConfig(config) + importNtfyConfig(config) fmt.Println(t) + + return config, store } -func importDomains(appState *state.AppState) string { +func importDomains(store *state.DomainStore) string { log := "" domainsEnv := os.Getenv("DOMAINS") @@ -49,37 +56,39 @@ func importDomains(appState *state.AppState) string { domains[i] = strings.TrimSpace(domains[i]) } - appState.Domains = make(map[string]state.Domain, len(domains)) + domainMap := make(map[string]state.Domain, len(domains)) for _, domain := range domains { if domain == "" { log = fmt.Sprintln(log, "[WARN] Empty domain found in the DOMAINS environment variable, skipping.") continue } - appState.Domains[domain] = state.Domain{ + domainMap[domain] = state.Domain{ Name: domain, ExpiryDate: time.Unix(0, 0), // Default expiry date } } - if len(appState.Domains) == 0 { + if len(domainMap) == 0 { panic("[ERROR] No valid domains found in the DOMAINS environment variable.") } - log = fmt.Sprintln(log, "Imported domains:", len(appState.Domains)) + store.SetDomains(domainMap) + + log = fmt.Sprintln(log, "Imported domains:", len(domainMap)) return log } -func importNotificationDaysConfig(appState *state.AppState) string { +func importNotificationDaysConfig(config *state.Config) string { log := "" daysEnv := os.Getenv("NOTIFICATION_DAYS") if daysEnv == "" { - appState.NotificationDays = []int{30, 15, 7, 1} // Default values + config.NotificationDays = []int{30, 15, 7, 1} // Default values log = fmt.Sprintln(log, "No NOTIFICATION_DAYS environment variable found, using default values...") - log = fmt.Sprintln(log, fmt.Sprint("Notification will be sent this many days before expiry: ", appState.NotificationDays)) + log = fmt.Sprintln(log, fmt.Sprint("Notification will be sent this many days before expiry: ", config.NotificationDays)) } else { daysStr := strings.Split(daysEnv, ",") @@ -87,7 +96,7 @@ func importNotificationDaysConfig(appState *state.AppState) string { panic("[ERROR] No valid days found in NOTIFICATION_DAYS environment variable.") } - appState.NotificationDays = make([]int, 0, len(daysStr)) + config.NotificationDays = make([]int, 0, len(daysStr)) for _, day := range daysStr { value, err := strconv.Atoi(strings.TrimSpace(day)) @@ -101,62 +110,62 @@ func importNotificationDaysConfig(appState *state.AppState) string { // Check for duplicates in the slice alreadyExists := false - for j := range len(appState.NotificationDays) { - if appState.NotificationDays[j] == value { + for j := range len(config.NotificationDays) { + if config.NotificationDays[j] == value { alreadyExists = true break } } if !alreadyExists { - appState.NotificationDays = append(appState.NotificationDays, value) + config.NotificationDays = append(config.NotificationDays, value) } } - slices.Sort(appState.NotificationDays) + slices.Sort(config.NotificationDays) - log = fmt.Sprintln(log, fmt.Sprint("Notification will be sent this many days before expiry:", appState.NotificationDays)) + log = fmt.Sprintln(log, fmt.Sprint("Notification will be sent this many days before expiry:", config.NotificationDays)) } return log } -func importTelegramConfig(appState *state.AppState) { - appState.TelegramNotification = os.Getenv("TELEGRAM_NOTIFICATION") == "true" - appState.TelegramChatID = os.Getenv("TELEGRAM_CHAT_ID") - appState.TelegramToken = os.Getenv("TELEGRAM_TOKEN") +func importTelegramConfig(config *state.Config) { + config.TelegramNotification = os.Getenv("TELEGRAM_NOTIFICATION") == "true" + config.TelegramChatID = os.Getenv("TELEGRAM_CHAT_ID") + config.TelegramToken = os.Getenv("TELEGRAM_TOKEN") - if appState.TelegramNotification && (appState.TelegramChatID == "" || appState.TelegramToken == "") { + if config.TelegramNotification && (config.TelegramChatID == "" || config.TelegramToken == "") { panic("[ERROR] Telegram notification is enabled but chat ID or token is not set.") } - if appState.TelegramNotification && appState.TelegramChatID != "" && appState.TelegramToken != "" { - println("[INFO] │ Telegram notification enabled to channel", appState.TelegramChatID) + if config.TelegramNotification && config.TelegramChatID != "" && config.TelegramToken != "" { + println("[INFO] │ Telegram notification enabled to channel", config.TelegramChatID) } } -func importDiscordConfig(appState *state.AppState) { - appState.DiscordNotification = os.Getenv("DISCORD_NOTIFICATION") == "true" - appState.DiscordWebhookURL = os.Getenv("DISCORD_WEBHOOK_URL") +func importDiscordConfig(config *state.Config) { + config.DiscordNotification = os.Getenv("DISCORD_NOTIFICATION") == "true" + config.DiscordWebhookURL = os.Getenv("DISCORD_WEBHOOK_URL") - if appState.DiscordNotification && appState.DiscordWebhookURL == "" { + if config.DiscordNotification && config.DiscordWebhookURL == "" { panic("[ERROR] Discord notification is enabled but webhook URL is not set.") } - if appState.DiscordNotification && appState.DiscordWebhookURL != "" { - println("[INFO] │ Discord notification enabled to webhook", appState.DiscordWebhookURL) + if config.DiscordNotification && config.DiscordWebhookURL != "" { + println("[INFO] │ Discord notification enabled to webhook", config.DiscordWebhookURL) } } -func importNtfyConfig(appState *state.AppState) { - appState.NtfyNotification = os.Getenv("NTFY_NOTIFICATION") == "true" - appState.NtfyURL = os.Getenv("NTFY_URL") +func importNtfyConfig(config *state.Config) { + config.NtfyNotification = os.Getenv("NTFY_NOTIFICATION") == "true" + config.NtfyURL = os.Getenv("NTFY_URL") - if appState.NtfyNotification && appState.NtfyURL == "" { + if config.NtfyNotification && config.NtfyURL == "" { panic("[ERROR] Ntfy notification is enabled but webhook URL is not set.") } - if appState.NtfyNotification && appState.NtfyURL != "" { - println("[INFO] Ntfy notification enabled to webhook", appState.NtfyURL) + if config.NtfyNotification && config.NtfyURL != "" { + println("[INFO] Ntfy notification enabled to webhook", config.NtfyURL) } } diff --git a/internal/utils/notifications.go b/internal/utils/notifications.go index 11a7122..d25005b 100644 --- a/internal/utils/notifications.go +++ b/internal/utils/notifications.go @@ -8,35 +8,39 @@ import ( "github.com/iwa/Expira/internal/utils/providers" ) -func Notify(appState *state.AppState) { +// Notify checks all domains and sends notifications if they are approaching expiry. +// It uses the provided store for domain data and config for notification settings. +func Notify(store *state.DomainStore, config *state.Config) { println("[INFO] Sending notifications...") - for domain, domainData := range appState.Domains { - daysUntil, shouldNotify := checkDaysForNotification(domainData.ExpiryDate, appState.NotificationDays) + domains := store.GetAllDomains() + + for domain, domainData := range domains { + daysUntil, shouldNotify := checkDaysForNotification(domainData.ExpiryDate, config.NotificationDays) if shouldNotify { - if appState.TelegramNotification && (appState.TelegramChatID != "" && appState.TelegramToken != "") { + if config.TelegramNotification && (config.TelegramChatID != "" && config.TelegramToken != "") { message := fmt.Sprintf("⚠️ Domain %s will expire in %d days \nExpiry date: %s", domain, daysUntil, domainData.ExpiryDate.Format("2006-01-02 15:04:05")) - err := providers.SendTelegramMessage(appState, message) + err := providers.SendTelegramMessage(config, message) if err != nil { println("[ERROR] Failed to send notification for domain", domain, ":", err) } } - if appState.DiscordNotification && appState.DiscordWebhookURL != "" { + if config.DiscordNotification && config.DiscordWebhookURL != "" { message := fmt.Sprintf("**⚠️ Domain %s will expire in %d days**\nExpiry date: `%s`", domain, daysUntil, domainData.ExpiryDate.Format("2006-01-02 15:04:05")) - err := providers.SendDiscordMessage(appState, message) + err := providers.SendDiscordMessage(config, message) if err != nil { println("[ERROR] Failed to send notification for domain", domain, ":", err) } } - if appState.NtfyNotification && appState.NtfyURL != "" { + if config.NtfyNotification && config.NtfyURL != "" { message := fmt.Sprintf("Domain %s will expire in %d days \nExpiry date: %s", domain, daysUntil, domainData.ExpiryDate.Format("2006-01-02 15:04:05")) - err := providers.SendNtfyMessage(appState, message) + err := providers.SendNtfyMessage(config, message) if err != nil { println("[ERROR] Failed to send notification for domain", domain, ":", err) } diff --git a/internal/utils/providers/discord.go b/internal/utils/providers/discord.go index 3c82fb9..0e2336c 100644 --- a/internal/utils/providers/discord.go +++ b/internal/utils/providers/discord.go @@ -14,7 +14,9 @@ type DiscordMessage struct { Content string `json:"content"` } -func SendDiscordMessage(appState *state.AppState, message string) error { +// SendDiscordMessage sends a notification message via Discord webhook. +// It uses configuration from the provided Config instance. +func SendDiscordMessage(config *state.Config, message string) error { payload := DiscordMessage{ Content: message, } @@ -24,7 +26,7 @@ func SendDiscordMessage(appState *state.AppState, message string) error { return err } - resp, err := http.Post(appState.DiscordWebhookURL, "application/json", bytes.NewBuffer(data)) + resp, err := http.Post(config.DiscordWebhookURL, "application/json", bytes.NewBuffer(data)) if err != nil { return err } diff --git a/internal/utils/providers/ntfy.go b/internal/utils/providers/ntfy.go index b99d6ab..a4d64c0 100644 --- a/internal/utils/providers/ntfy.go +++ b/internal/utils/providers/ntfy.go @@ -9,8 +9,10 @@ import ( "github.com/iwa/Expira/internal/state" ) -func SendNtfyMessage(appState *state.AppState, message string) error { - req, _ := http.NewRequest("POST", appState.NtfyURL, +// SendNtfyMessage sends a notification message via Ntfy. +// It uses configuration from the provided Config instance. +func SendNtfyMessage(config *state.Config, message string) error { + req, _ := http.NewRequest("POST", config.NtfyURL, strings.NewReader(message)) req.Header.Set("Title", "Domain expiry alert") diff --git a/internal/utils/providers/telegram.go b/internal/utils/providers/telegram.go index 1292fe4..d2f7a4b 100644 --- a/internal/utils/providers/telegram.go +++ b/internal/utils/providers/telegram.go @@ -18,9 +18,11 @@ type TelegramMessage struct { ProtectContent bool `json:"protect_content"` } -func SendTelegramMessage(appState *state.AppState, message string) error { +// SendTelegramMessage sends a notification message via Telegram API. +// It uses configuration from the provided Config instance. +func SendTelegramMessage(config *state.Config, message string) error { payload := TelegramMessage{ - ChatID: appState.TelegramChatID, + ChatID: config.TelegramChatID, Text: message, ParseMode: "HTML", DisableNotification: true, @@ -32,7 +34,7 @@ func SendTelegramMessage(appState *state.AppState, message string) error { return err } - resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", appState.TelegramToken), "application/json", bytes.NewBuffer(data)) + resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", config.TelegramToken), "application/json", bytes.NewBuffer(data)) if err != nil { return err } diff --git a/internal/utils/whois.go b/internal/utils/whois.go index 678068a..ac83d19 100644 --- a/internal/utils/whois.go +++ b/internal/utils/whois.go @@ -12,21 +12,26 @@ import ( "github.com/iwa/Expira/internal/state" ) -func UpdateDomains(appState *state.AppState) { +// UpdateDomains queries WHOIS servers for all domains in the store and updates their expiry dates. +// This function is thread-safe and can be called concurrently with other store operations. +func UpdateDomains(store *state.DomainStore) { println("[INFO] Updating domains...") var wg sync.WaitGroup - for domain, domainData := range appState.Domains { + // Get all domains from the store + domains := store.GetAllDomains() + + for domain, domainData := range domains { wg.Add(1) - go updateDomainExpiry(appState, domain, domainData, &wg) + go updateDomainExpiry(store, domain, domainData, &wg) } wg.Wait() println("[INFO] All domains updated.") } -func updateDomainExpiry(appState *state.AppState, domain string, domainData state.Domain, wg *sync.WaitGroup) { +func updateDomainExpiry(store *state.DomainStore, domain string, domainData state.Domain, wg *sync.WaitGroup) { defer wg.Done() res, err := getDomainExpiry(domain) @@ -41,7 +46,7 @@ func updateDomainExpiry(appState *state.AppState, domain string, domainData stat } domainData.ExpiryDate = res - appState.Domains[domain] = domainData + store.SetDomain(domain, domainData) println("[INFO] Domain:", domain, "- Expiry date:", res.Format("2006-01-02 15:04:05")) } From e005fcca90038357a90cb4c85f1f8a192c05f58f Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 00:08:48 +0100 Subject: [PATCH 2/9] feat: centralize into App struct --- cmd/expira/main.go | 28 +++------------------------- internal/app/app.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 25 deletions(-) create mode 100644 internal/app/app.go diff --git a/cmd/expira/main.go b/cmd/expira/main.go index a4d5810..fd39276 100644 --- a/cmd/expira/main.go +++ b/cmd/expira/main.go @@ -2,12 +2,9 @@ package main import ( "fmt" - "net/http" "github.com/charmbracelet/lipgloss" - "github.com/iwa/Expira/internal/api" - "github.com/iwa/Expira/internal/cron" - "github.com/iwa/Expira/internal/utils" + "github.com/iwa/Expira/internal/app" ) var titleStyle = lipgloss.NewStyle(). @@ -24,27 +21,8 @@ var titleStyle = lipgloss.NewStyle(). func main() { fmt.Println(titleStyle.Render("Domain Expiry Watcher")) - // Load configuration and initialize domain store using dependency injection - config, store := utils.LoadConfig() - - println("[INFO] Starting domain expiry watcher...") - - // Update domain expiry dates from WHOIS servers - utils.UpdateDomains(store) - - // Display domain status in console - utils.ReportStatusInConsole(store) - - // Send initial notifications if needed - utils.Notify(store, config) - - // Setup HTTP API endpoints with dependency injection - http.HandleFunc("/health", api.HealthHandler) - http.HandleFunc("/status", api.StatusHandlerFactory(store)) - go http.ListenAndServe("0.0.0.0:8080", nil) - - // Start cron job for daily domain updates - cron.StartCronLoop(store, config) + app := app.New() + app.Start() select {} // Keep the main goroutine running } diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..9c3908c --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,41 @@ +package app + +import ( + "net/http" + + "github.com/iwa/Expira/internal/api" + "github.com/iwa/Expira/internal/cron" + "github.com/iwa/Expira/internal/state" + "github.com/iwa/Expira/internal/utils" +) + +// App holds the application's core dependencies +type App struct { + Config *state.Config + Store *state.DomainStore +} + +// New creates and initializes a new App instance +func New() *App { + config, store := utils.LoadConfig() + return &App{ + Config: config, + Store: store, + } +} + +// Start runs the application +func (app *App) Start() { + // Initial domain update and notification + utils.UpdateDomains(app.Store) + utils.ReportStatusInConsole(app.Store) + utils.Notify(app.Store, app.Config) + + // Setup HTTP server + http.HandleFunc("/health", api.HealthHandler) + http.HandleFunc("/status", api.StatusHandlerFactory(app.Store)) + go http.ListenAndServe("0.0.0.0:8080", nil) + + // Start cron job + cron.StartCronLoop(app.Store, app.Config) +} From 9982115f7727ad1391cd9d3d693f7b1c033ddef8 Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 14:37:55 +0100 Subject: [PATCH 3/9] chore: reword some comments --- internal/cron/cron.go | 2 +- internal/state/Config.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cron/cron.go b/internal/cron/cron.go index aadc1e0..5fc78d8 100644 --- a/internal/cron/cron.go +++ b/internal/cron/cron.go @@ -7,7 +7,7 @@ import ( "github.com/iwa/Expira/internal/utils" ) -// StartCronLoop starts a hourly cron job that runs domain updates at midnight. +// StartCronLoop starts an hourly cron job that runs domain updates at midnight. // It uses dependency injection to access the domain store and configuration. func StartCronLoop(store *state.DomainStore, config *state.Config) { println("[INFO] Starting cron job...") diff --git a/internal/state/Config.go b/internal/state/Config.go index 01a0717..c874163 100644 --- a/internal/state/Config.go +++ b/internal/state/Config.go @@ -1,7 +1,7 @@ package state -// Config holds immutable configuration loaded from environment variables at startup. -// This configuration does not change during the application lifecycle. +// Config holds configuration loaded from environment variables at startup. +// This configuration should never change during the application lifecycle. type Config struct { NotificationDays []int From d1284b3b1f29ed228725583f51d1fed602a56ca3 Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 14:44:18 +0100 Subject: [PATCH 4/9] fix: send correct http code if method not allowed --- internal/api/routes.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/api/routes.go b/internal/api/routes.go index aa6d25c..676b5dd 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -9,6 +9,7 @@ import ( func HealthHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } @@ -20,6 +21,7 @@ func HealthHandler(w http.ResponseWriter, r *http.Request) { func StatusHandlerFactory(store *state.DomainStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } From 6e08f05077ef27f19a26413f079b2d3f36dc29ec Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 14:46:50 +0100 Subject: [PATCH 5/9] feat: handle err when construction post request for ntfy --- internal/utils/providers/ntfy.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/utils/providers/ntfy.go b/internal/utils/providers/ntfy.go index a4d64c0..d704cbb 100644 --- a/internal/utils/providers/ntfy.go +++ b/internal/utils/providers/ntfy.go @@ -12,8 +12,10 @@ import ( // SendNtfyMessage sends a notification message via Ntfy. // It uses configuration from the provided Config instance. func SendNtfyMessage(config *state.Config, message string) error { - req, _ := http.NewRequest("POST", config.NtfyURL, - strings.NewReader(message)) + req, err := http.NewRequest("POST", config.NtfyURL, strings.NewReader(message)) + if err != nil { + return err + } req.Header.Set("Title", "Domain expiry alert") req.Header.Set("Priority", "urgent") From 9fff371ffaf2fc9c5bbb613e642e7502f974546a Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 14:52:05 +0100 Subject: [PATCH 6/9] chore: remove some useless comments --- internal/state/DomainStore.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/state/DomainStore.go b/internal/state/DomainStore.go index d2ab7f9..795427a 100644 --- a/internal/state/DomainStore.go +++ b/internal/state/DomainStore.go @@ -4,21 +4,17 @@ import ( "sync" ) -// DomainStore provides thread-safe access to domain data. -// It uses a read-write mutex to allow concurrent reads while ensuring safe writes. type DomainStore struct { mu sync.RWMutex domains map[string]Domain } -// NewDomainStore creates a new DomainStore instance func NewDomainStore() *DomainStore { return &DomainStore{ domains: make(map[string]Domain), } } -// GetDomain retrieves a domain by name. // Returns the domain and true if found, or an empty domain and false if not found. func (ds *DomainStore) GetDomain(name string) (Domain, bool) { ds.mu.RLock() @@ -28,7 +24,6 @@ func (ds *DomainStore) GetDomain(name string) (Domain, bool) { return domain, ok } -// SetDomain stores or updates a domain. // This method is thread-safe and can be called from multiple goroutines. func (ds *DomainStore) SetDomain(name string, domain Domain) { ds.mu.Lock() From d1a872de268d8fdc3cfb79566d4fb4063262a3ae Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 14:52:21 +0100 Subject: [PATCH 7/9] feat: replace loops with modern maps.Copy --- internal/state/DomainStore.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/state/DomainStore.go b/internal/state/DomainStore.go index 795427a..9ff0300 100644 --- a/internal/state/DomainStore.go +++ b/internal/state/DomainStore.go @@ -1,6 +1,7 @@ package state import ( + "maps" "sync" ) @@ -40,20 +41,20 @@ func (ds *DomainStore) GetAllDomains() map[string]Domain { // Create a copy to avoid exposing internal map domainsCopy := make(map[string]Domain, len(ds.domains)) - for k, v := range ds.domains { - domainsCopy[k] = v - } + maps.Copy(domainsCopy, ds.domains) return domainsCopy } -// SetDomains replaces all domains with the provided map. -// This is useful for bulk initialization. -func (ds *DomainStore) SetDomains(domains map[string]Domain) { +// SetBulkDomains replaces all domains with the provided map. +func (ds *DomainStore) SetBulkDomains(domains map[string]Domain) { ds.mu.Lock() defer ds.mu.Unlock() - ds.domains = domains + domainsCopy := make(map[string]Domain, len(domains)) + maps.Copy(domainsCopy, domains) + + ds.domains = domainsCopy } // Count returns the number of domains in the store From 5517bf8a9f4e7215865b76bd27397118bfcf8bb3 Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 14:53:39 +0100 Subject: [PATCH 8/9] fix: usage of renamed SetBulkDomains function --- internal/utils/env_import.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/utils/env_import.go b/internal/utils/env_import.go index d6de8cb..af6751f 100644 --- a/internal/utils/env_import.go +++ b/internal/utils/env_import.go @@ -73,7 +73,7 @@ func importDomains(store *state.DomainStore) string { panic("[ERROR] No valid domains found in the DOMAINS environment variable.") } - store.SetDomains(domainMap) + store.SetBulkDomains(domainMap) log = fmt.Sprintln(log, "Imported domains:", len(domainMap)) From c784a991b64d50e669851d6b33afa006df9b2117 Mon Sep 17 00:00:00 2001 From: iwa Date: Sun, 21 Dec 2025 15:10:28 +0100 Subject: [PATCH 9/9] refactor: create a Server struct for http server + handle possible errors --- cmd/expira/main.go | 7 +++--- internal/api/server.go | 52 ++++++++++++++++++++++++++++++++++++++++++ internal/app/app.go | 34 ++++++++++++++++++++------- 3 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 internal/api/server.go diff --git a/cmd/expira/main.go b/cmd/expira/main.go index fd39276..7731cd1 100644 --- a/cmd/expira/main.go +++ b/cmd/expira/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "log" "github.com/charmbracelet/lipgloss" "github.com/iwa/Expira/internal/app" @@ -22,7 +23,7 @@ func main() { fmt.Println(titleStyle.Render("Domain Expiry Watcher")) app := app.New() - app.Start() - - select {} // Keep the main goroutine running + if err := app.Start(); err != nil { + log.Fatalf("Application error: %v", err) + } } diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..c92eb8c --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,52 @@ +package api + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/iwa/Expira/internal/state" +) + +// Server wraps the HTTP server with graceful lifecycle management +type Server struct { + server *http.Server + errors chan error +} + +func NewServer(addr string, store *state.DomainStore) *Server { + mux := http.NewServeMux() + mux.HandleFunc("/health", HealthHandler) + mux.HandleFunc("/status", StatusHandlerFactory(store)) + + return &Server{ + server: &http.Server{ + Addr: addr, + Handler: mux, + }, + errors: make(chan error, 1), + } +} + +// Start begins listening for HTTP requests in a goroutine +func (s *Server) Start() { + go func() { + fmt.Printf("[INFO] HTTP server starting on %s\n", s.server.Addr) + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + s.errors <- fmt.Errorf("[ERROR] HTTP server error: %w", err) + } + }() +} + +// Errors returns a channel that receives server errors +func (s *Server) Errors() <-chan error { + return s.errors +} + +// Shutdown gracefully stops the server with a timeout +func (s *Server) Shutdown(timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return s.server.Shutdown(ctx) +} diff --git a/internal/app/app.go b/internal/app/app.go index 9c3908c..acf47a9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,7 +1,8 @@ package app import ( - "net/http" + "fmt" + "time" "github.com/iwa/Expira/internal/api" "github.com/iwa/Expira/internal/cron" @@ -25,17 +26,34 @@ func New() *App { } // Start runs the application -func (app *App) Start() { +func (app *App) Start() error { // Initial domain update and notification utils.UpdateDomains(app.Store) utils.ReportStatusInConsole(app.Store) utils.Notify(app.Store, app.Config) - // Setup HTTP server - http.HandleFunc("/health", api.HealthHandler) - http.HandleFunc("/status", api.StatusHandlerFactory(app.Store)) - go http.ListenAndServe("0.0.0.0:8080", nil) + // Start HTTP server + server := api.NewServer("0.0.0.0:8080", app.Store) + server.Start() - // Start cron job - cron.StartCronLoop(app.Store, app.Config) + // Start cron job in goroutine + cronErrors := make(chan error, 1) + go func() { + cron.StartCronLoop(app.Store, app.Config) + cronErrors <- fmt.Errorf("cron loop unexpectedly stopped") + }() + + // Block the main go routine with error handling + select { + case err := <-server.Errors(): + if shutdownErr := server.Shutdown(5 * time.Second); shutdownErr != nil { + return fmt.Errorf("server error: %w, shutdown error: %v", err, shutdownErr) + } + return err + case err := <-cronErrors: + if shutdownErr := server.Shutdown(5 * time.Second); shutdownErr != nil { + return fmt.Errorf("cron error: %w, shutdown error: %v", err, shutdownErr) + } + return err + } }