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
30 changes: 6 additions & 24 deletions cmd/expira/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ package main

import (
"fmt"
"net/http"
"log"

"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"
"github.com/iwa/Expira/internal/app"
)

var titleStyle = lipgloss.NewStyle().
Expand All @@ -25,23 +22,8 @@ var titleStyle = lipgloss.NewStyle().
func main() {
fmt.Println(titleStyle.Render("Domain Expiry Watcher"))

appState := state.GetInstance()

utils.ImportEnv(appState)

println("[INFO] Starting domain expiry watcher...")

utils.UpdateDomains(appState)

utils.ReportStatusInConsole(appState)

utils.Notify(appState)

http.HandleFunc("/health", api.HealthHandler)
http.HandleFunc("/status", api.StatusHandler)
go http.ListenAndServe("0.0.0.0:8080", nil)

cron.StartCronLoop(appState)

select {} // Keep the main goroutine running
app := app.New()
if err := app.Start(); err != nil {
log.Fatalf("Application error: %v", err)
}
}
34 changes: 19 additions & 15 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,34 @@ import (

func HealthHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

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 {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
Comment thread
iwa marked this conversation as resolved.

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)
}
}
52 changes: 52 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
@@ -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)
}
59 changes: 59 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package app

import (
"fmt"
"time"

"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() error {
// Initial domain update and notification
utils.UpdateDomains(app.Store)
utils.ReportStatusInConsole(app.Store)
utils.Notify(app.Store, app.Config)

// Start HTTP server
server := api.NewServer("0.0.0.0:8080", app.Store)
server.Start()

// 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
}
}
10 changes: 6 additions & 4 deletions internal/cron/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"github.com/iwa/Expira/internal/utils"
)

func StartCronLoop(appState *state.AppState) {
// 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...")

ticker := time.NewTicker(time.Hour)
Expand All @@ -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)
}
}
}
Expand Down
27 changes: 0 additions & 27 deletions internal/state/AppState.go

This file was deleted.

24 changes: 24 additions & 0 deletions internal/state/Config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package state

// Config holds configuration loaded from environment variables at startup.
// This configuration should never 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{},
}
}
66 changes: 66 additions & 0 deletions internal/state/DomainStore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package state

import (
"maps"
"sync"
)

type DomainStore struct {
mu sync.RWMutex
domains map[string]Domain
}

func NewDomainStore() *DomainStore {
return &DomainStore{
domains: make(map[string]Domain),
}
}

// 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
}

// 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))
maps.Copy(domainsCopy, ds.domains)

return domainsCopy
}

// SetBulkDomains replaces all domains with the provided map.
func (ds *DomainStore) SetBulkDomains(domains map[string]Domain) {
ds.mu.Lock()
defer ds.mu.Unlock()

domainsCopy := make(map[string]Domain, len(domains))
maps.Copy(domainsCopy, domains)

ds.domains = domainsCopy
}

// 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)
}
7 changes: 5 additions & 2 deletions internal/utils/cli_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
Loading
Loading