Refactor configuration system with centralized type-safe config package

- Create internal/config package with unified config structs and validation
- Abstract viper dependency behind config.Loader interface for better testability
- Replace manual config parsing and type assertions with type-safe loading
- Consolidate AuthConfig, SiteConfig, and DiscoveryConfig into single package
- Add comprehensive validation with clear error messages
- Remove ~200 lines of duplicate config handling code
- Maintain backward compatibility with existing config files
This commit is contained in:
2025-10-08 17:58:03 +02:00
parent 2959ecedf9
commit 38c2897ece
11 changed files with 550 additions and 332 deletions

View File

@@ -8,7 +8,6 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/insertr/insertr/internal/content"
"github.com/insertr/insertr/internal/db"
@@ -34,9 +33,6 @@ var (
func init() {
enhanceCmd.Flags().StringVarP(&outputDir, "output", "o", "./dist", "Output directory for enhanced files")
// Bind flags to viper
viper.BindPFlag("cli.output", enhanceCmd.Flags().Lookup("output"))
}
func runEnhance(cmd *cobra.Command, args []string) {
@@ -59,20 +55,26 @@ func runEnhance(cmd *cobra.Command, args []string) {
}
}
// Get configuration values
dbPath := viper.GetString("database.path")
apiURL := viper.GetString("api.url")
apiKey := viper.GetString("api.key")
siteID := viper.GetString("cli.site_id")
outputDir := viper.GetString("cli.output")
// Load configuration
cfg, err := loadConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Override with flags if provided
if outputDir != "" {
// No output config in main config, use the flag value directly
} else {
outputDir = "./dist" // default
}
// Auto-derive site_id for demo paths or validate for production
if strings.Contains(inputPath, "/demos/") || strings.Contains(inputPath, "./demos/") {
// Auto-derive site_id from demo path
siteID = content.DeriveOrValidateSiteID(inputPath, siteID)
cfg.CLI.SiteID = content.DeriveOrValidateSiteID(inputPath, cfg.CLI.SiteID)
} else {
// Validate site_id for non-demo paths
if siteID == "" || siteID == "demo" {
if cfg.CLI.SiteID == "" || cfg.CLI.SiteID == "demo" {
log.Fatalf(`❌ site_id must be explicitly configured for non-demo sites.
💡 Examples:
@@ -92,12 +94,12 @@ func runEnhance(cmd *cobra.Command, args []string) {
// Create content client
var client engine.ContentClient
if apiURL != "" {
fmt.Printf("🌐 Using content API: %s\n", apiURL)
client = content.NewHTTPClient(apiURL, apiKey)
} else if dbPath != "" {
fmt.Printf("🗄️ Using database: %s\n", dbPath)
database, err := db.NewDatabase(dbPath)
if cfg.API.URL != "" {
fmt.Printf("🌐 Using content API: %s\n", cfg.API.URL)
client = content.NewHTTPClient(cfg.API.URL, cfg.API.Key)
} else if cfg.Database.Path != "" {
fmt.Printf("🗄️ Using database: %s\n", cfg.Database.Path)
database, err := db.NewDatabase(cfg.Database.Path)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
@@ -121,41 +123,24 @@ func runEnhance(cmd *cobra.Command, args []string) {
}
// Override with site-specific discovery config if available
if siteConfigs := viper.Get("server.sites"); siteConfigs != nil {
if configs, ok := siteConfigs.([]interface{}); ok {
for _, configInterface := range configs {
if configMap, ok := configInterface.(map[string]interface{}); ok {
if configSiteID, ok := configMap["site_id"].(string); ok && configSiteID == siteID {
// Found matching site config, load discovery settings
if discoveryMap, ok := configMap["discovery"].(map[string]interface{}); ok {
if enabled, ok := discoveryMap["enabled"].(bool); ok {
enhancementConfig.Discovery.Enabled = enabled
fmt.Printf("🔧 Site '%s': discovery.enabled=%v\n", siteID, enabled)
}
if aggressive, ok := discoveryMap["aggressive"].(bool); ok {
enhancementConfig.Discovery.Aggressive = aggressive
}
if containers, ok := discoveryMap["containers"].(bool); ok {
enhancementConfig.Discovery.Containers = containers
}
if individual, ok := discoveryMap["individual"].(bool); ok {
enhancementConfig.Discovery.Individual = individual
}
}
break
}
}
}
for _, site := range cfg.Server.Sites {
if site.SiteID == cfg.CLI.SiteID && site.Discovery != nil {
enhancementConfig.Discovery.Enabled = site.Discovery.Enabled
enhancementConfig.Discovery.Aggressive = site.Discovery.Aggressive
enhancementConfig.Discovery.Containers = site.Discovery.Containers
enhancementConfig.Discovery.Individual = site.Discovery.Individual
fmt.Printf("🔧 Site '%s': discovery.enabled=%v\n", cfg.CLI.SiteID, site.Discovery.Enabled)
break
}
}
// Create enhancer with loaded configuration
enhancer := content.NewEnhancer(client, siteID, enhancementConfig)
enhancer := content.NewEnhancer(client, cfg.CLI.SiteID, enhancementConfig)
fmt.Printf("🚀 Starting enhancement process...\n")
fmt.Printf("📁 Input: %s\n", inputPath)
fmt.Printf("📁 Output: %s\n", outputDir)
fmt.Printf("🏷️ Site ID: %s\n\n", siteID)
fmt.Printf("🏷️ Site ID: %s\n\n", cfg.CLI.SiteID)
// Enhance based on input type
if isFile {

View File

@@ -4,16 +4,17 @@ import (
"fmt"
"os"
"github.com/insertr/insertr/internal/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
dbPath string
apiURL string
apiKey string
siteID string
configFile string
dbPath string
apiURL string
apiKey string
siteID string
loader config.Loader
)
var rootCmd = &cobra.Command{
@@ -35,40 +36,22 @@ func Execute() {
}
func init() {
cobra.OnInitialize(initConfig)
loader = config.NewLoader()
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./insertr.yaml)")
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "./insertr.db", "database path (SQLite file or PostgreSQL connection string)")
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is ./insertr.yaml)")
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "database path (SQLite file or PostgreSQL connection string)")
rootCmd.PersistentFlags().StringVar(&apiURL, "api-url", "", "content API URL")
rootCmd.PersistentFlags().StringVar(&apiKey, "api-key", "", "API key for authentication")
rootCmd.PersistentFlags().StringVarP(&siteID, "site-id", "s", "demo", "site ID for content lookup")
// Bind flags to viper
viper.BindPFlag("database.path", rootCmd.PersistentFlags().Lookup("db"))
viper.BindPFlag("api.url", rootCmd.PersistentFlags().Lookup("api-url"))
viper.BindPFlag("api.key", rootCmd.PersistentFlags().Lookup("api-key"))
viper.BindPFlag("cli.site_id", rootCmd.PersistentFlags().Lookup("site-id"))
rootCmd.PersistentFlags().StringVarP(&siteID, "site-id", "s", "", "site ID for content lookup")
rootCmd.AddCommand(enhanceCmd)
rootCmd.AddCommand(serveCmd)
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.AddConfigPath(".")
viper.SetConfigName("insertr")
viper.SetConfigType("yaml")
}
// Environment variables
viper.SetEnvPrefix("INSERTR")
viper.AutomaticEnv()
// Read config file
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
func loadConfig() (*config.Config, error) {
if configFile != "" || dbPath != "" || apiURL != "" || apiKey != "" || siteID != "" {
return loader.LoadWithFlags(dbPath, apiURL, apiKey, siteID)
}
return loader.Load(configFile)
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/insertr/insertr/internal/api"
"github.com/insertr/insertr/internal/auth"
@@ -36,72 +35,65 @@ var (
)
func init() {
serveCmd.Flags().IntVarP(&port, "port", "p", 8080, "Server port")
serveCmd.Flags().IntVarP(&port, "port", "p", 0, "Server port")
serveCmd.Flags().BoolVar(&devMode, "dev-mode", false, "Enable development mode features")
// Bind flags to viper
viper.BindPFlag("server.port", serveCmd.Flags().Lookup("port"))
viper.BindPFlag("dev_mode", serveCmd.Flags().Lookup("dev-mode"))
}
func runServe(cmd *cobra.Command, args []string) {
// Get configuration values
port := viper.GetInt("server.port")
dbPath := viper.GetString("database.path")
devMode := viper.GetBool("dev_mode")
// Load configuration
cfg, err := loadConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
// Override with flags if provided
if port != 0 {
cfg.Server.Port = port
}
if devMode {
cfg.Auth.DevMode = true
}
// Initialize database
database, err := db.NewDatabase(dbPath)
database, err := db.NewDatabase(cfg.Database.Path)
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.Close()
// Initialize authentication service
// Support environment variables for sensitive values
if clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET"); clientSecret != "" && cfg.Auth.OIDC != nil {
cfg.Auth.OIDC.ClientSecret = clientSecret
}
if endpoint := os.Getenv("AUTHENTIK_ENDPOINT"); endpoint != "" && cfg.Auth.OIDC != nil {
cfg.Auth.OIDC.Endpoint = endpoint
}
// Set redirect URL if not configured
if cfg.Auth.OIDC != nil && cfg.Auth.OIDC.RedirectURL == "" {
cfg.Auth.OIDC.RedirectURL = fmt.Sprintf("http://%s:%d/auth/callback", cfg.Server.Host, cfg.Server.Port)
}
// Create legacy auth config for compatibility
authConfig := &auth.AuthConfig{
DevMode: viper.GetBool("dev_mode"),
Provider: viper.GetString("auth.provider"),
JWTSecret: viper.GetString("auth.jwt_secret"),
DevMode: cfg.Auth.DevMode,
Provider: cfg.Auth.Provider,
JWTSecret: cfg.Auth.JWTSecret,
}
// Set default values
if authConfig.Provider == "" {
authConfig.Provider = "mock"
}
if authConfig.JWTSecret == "" {
authConfig.JWTSecret = "dev-secret-change-in-production"
if authConfig.DevMode {
log.Printf("🔑 Using default JWT secret for development")
if cfg.Auth.OIDC != nil {
authConfig.OIDC = &auth.OIDCConfig{
Endpoint: cfg.Auth.OIDC.Endpoint,
ClientID: cfg.Auth.OIDC.ClientID,
ClientSecret: cfg.Auth.OIDC.ClientSecret,
RedirectURL: cfg.Auth.OIDC.RedirectURL,
Scopes: cfg.Auth.OIDC.Scopes,
}
}
// Configure OIDC if using authentik
if authConfig.Provider == "authentik" {
oidcConfig := &auth.OIDCConfig{
Endpoint: viper.GetString("auth.oidc.endpoint"),
ClientID: viper.GetString("auth.oidc.client_id"),
ClientSecret: viper.GetString("auth.oidc.client_secret"),
RedirectURL: fmt.Sprintf("http://localhost:%d/auth/callback", port),
}
// Support environment variables for sensitive values
if clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET"); clientSecret != "" {
oidcConfig.ClientSecret = clientSecret
}
if endpoint := os.Getenv("AUTHENTIK_ENDPOINT"); endpoint != "" {
oidcConfig.Endpoint = endpoint
}
authConfig.OIDC = oidcConfig
// Validate required OIDC config
if oidcConfig.Endpoint == "" || oidcConfig.ClientID == "" || oidcConfig.ClientSecret == "" {
log.Fatalf("❌ Authentik OIDC configuration incomplete. Required: endpoint, client_id, client_secret")
}
log.Printf("🔐 Using Authentik OIDC provider: %s", oidcConfig.Endpoint)
} else {
log.Printf("🔑 Using auth provider: %s", authConfig.Provider)
log.Printf("🔑 Using auth provider: %s", cfg.Auth.Provider)
if cfg.Auth.Provider == "authentik" && cfg.Auth.OIDC != nil {
log.Printf("🔐 Using Authentik OIDC provider: %s", cfg.Auth.OIDC.Endpoint)
}
authService, err := auth.NewAuthService(authConfig)
@@ -113,64 +105,38 @@ func runServe(cmd *cobra.Command, args []string) {
contentClient := engine.NewDatabaseClient(database)
// Initialize site manager with auth provider
authProvider := &engine.AuthProvider{Type: authConfig.Provider}
siteManager := content.NewSiteManagerWithAuth(contentClient, devMode, authProvider)
authProvider := &engine.AuthProvider{Type: cfg.Auth.Provider}
siteManager := content.NewSiteManagerWithAuth(contentClient, cfg.Auth.DevMode, authProvider)
// Load sites from configuration
if siteConfigs := viper.Get("server.sites"); siteConfigs != nil {
if configs, ok := siteConfigs.([]interface{}); ok {
var sites []*content.SiteConfig
for _, configInterface := range configs {
if configMap, ok := configInterface.(map[string]interface{}); ok {
site := &content.SiteConfig{}
if siteID, ok := configMap["site_id"].(string); ok {
site.SiteID = siteID
}
if path, ok := configMap["path"].(string); ok {
site.Path = path
}
if sourcePath, ok := configMap["source_path"].(string); ok {
site.SourcePath = sourcePath
}
if domain, ok := configMap["domain"].(string); ok {
site.Domain = domain
}
if autoEnhance, ok := configMap["auto_enhance"].(bool); ok {
site.AutoEnhance = autoEnhance
}
// Parse discovery config if present
if discoveryMap, ok := configMap["discovery"].(map[string]interface{}); ok {
discovery := &content.DiscoveryConfig{
Containers: true, // defaults
Individual: true,
}
if enabled, ok := discoveryMap["enabled"].(bool); ok {
discovery.Enabled = enabled
}
if aggressive, ok := discoveryMap["aggressive"].(bool); ok {
discovery.Aggressive = aggressive
}
if containers, ok := discoveryMap["containers"].(bool); ok {
discovery.Containers = containers
}
if individual, ok := discoveryMap["individual"].(bool); ok {
discovery.Individual = individual
}
site.Discovery = discovery
}
if site.SiteID != "" && site.Path != "" {
sites = append(sites, site)
}
}
}
if err := siteManager.RegisterSites(sites); err != nil {
log.Printf("⚠️ Failed to register some sites: %v", err)
// Convert config sites to legacy format and register
var legacySites []*content.SiteConfig
for _, site := range cfg.Server.Sites {
legacySite := &content.SiteConfig{
SiteID: site.SiteID,
Path: site.Path,
SourcePath: site.SourcePath,
Domain: site.Domain,
AutoEnhance: site.AutoEnhance,
}
if site.Discovery != nil {
legacySite.Discovery = &content.DiscoveryConfig{
Enabled: site.Discovery.Enabled,
Aggressive: site.Discovery.Aggressive,
Containers: site.Discovery.Containers,
Individual: site.Discovery.Individual,
}
}
legacySites = append(legacySites, legacySite)
}
if len(legacySites) > 0 {
if err := siteManager.RegisterSites(legacySites); err != nil {
log.Printf("⚠️ Failed to register some sites: %v", err)
}
}
// Auto-enhance sites if enabled
if devMode {
if cfg.Auth.DevMode {
log.Printf("🔄 Auto-enhancing sites in development mode...")
if err := siteManager.EnhanceAllSites(); err != nil {
log.Printf("⚠️ Some sites failed to enhance: %v", err)
@@ -191,133 +157,89 @@ func runServe(cmd *cobra.Command, args []string) {
AllowedOrigins: []string{"*"}, // In dev mode, allow all origins
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
ExposedHeaders: []string{"Link"},
ExposedHeaders: []string{"*"},
AllowCredentials: true,
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
router.Use(api.ContentTypeMiddleware)
// Health check endpoint
router.Get("/health", api.HealthMiddleware())
// Static library serving (for demo sites)
router.Get("/insertr.js", contentHandler.ServeInsertrJS)
router.Get("/insertr.css", contentHandler.ServeInsertrCSS)
// Auth routes
router.Route("/auth", func(authRouter chi.Router) {
authRouter.Get("/login", authService.HandleOAuthLogin)
authRouter.Get("/callback", authService.HandleOAuthCallback)
// Health check
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// API routes
router.Route("/api", func(apiRouter chi.Router) {
// Site enhancement endpoint
apiRouter.Post("/enhance", contentHandler.EnhanceSite)
// Authentication routes
router.Route("/auth", func(r chi.Router) {
r.Post("/login", authService.HandleOAuthLogin)
r.Get("/callback", authService.HandleOAuthCallback)
})
// Content endpoints
apiRouter.Route("/content", func(contentRouter chi.Router) {
contentRouter.Get("/bulk", contentHandler.GetBulkContent)
contentRouter.Get("/{id}", contentHandler.GetContent)
contentRouter.Get("/", contentHandler.GetAllContent)
contentRouter.Post("/", contentHandler.CreateContent)
contentRouter.Put("/{id}", contentHandler.UpdateContent)
// Content API routes
router.Route("/api", func(r chi.Router) {
// Public routes
r.Get("/content/{siteID}/{id}", contentHandler.GetContent)
r.Get("/content/{siteID}", contentHandler.GetAllContent)
// Version control endpoints
contentRouter.Get("/{id}/versions", contentHandler.GetContentVersions)
contentRouter.Post("/{id}/rollback", contentHandler.RollbackContent)
})
// Protected routes (require authentication)
r.Group(func(r chi.Router) {
r.Use(authService.RequireAuth)
r.Post("/content/{siteID}", contentHandler.CreateContent)
r.Put("/content/{siteID}/{id}", contentHandler.UpdateContent)
r.Delete("/content/{siteID}/{id}", contentHandler.DeleteContent)
// Collection endpoints
apiRouter.Route("/collections", func(collectionRouter chi.Router) {
collectionRouter.Get("/", contentHandler.GetAllCollections)
collectionRouter.Get("/{id}", contentHandler.GetCollection)
// Collection item endpoints
collectionRouter.Get("/{id}/items", contentHandler.GetCollectionItems)
collectionRouter.Post("/{id}/items", contentHandler.CreateCollectionItem)
collectionRouter.Put("/{id}/items/{item_id}", contentHandler.UpdateCollectionItem)
collectionRouter.Delete("/{id}/items/{item_id}", contentHandler.DeleteCollectionItem)
// Bulk operations
collectionRouter.Put("/{id}/reorder", contentHandler.ReorderCollection)
// Version management
r.Get("/content/{siteID}/{id}/versions", contentHandler.GetContentVersions)
r.Post("/content/{siteID}/{id}/rollback/{version}", contentHandler.RollbackContent)
})
})
// Static site serving - serve registered sites at /sites/{site_id}
// Custom file server that fixes CSS MIME types
for siteID, siteConfig := range siteManager.GetAllSites() {
log.Printf("📁 Serving site %s from %s at /sites/%s/", siteID, siteConfig.Path, siteID)
// Serve static sites
for _, siteConfig := range siteManager.GetAllSites() {
log.Printf("📁 Serving site %s from %s at /sites/%s/", siteConfig.SiteID, siteConfig.Path, siteConfig.SiteID)
// Create custom file server with MIME type fixing
// Create a file server for each site
fileServer := http.FileServer(http.Dir(siteConfig.Path))
customHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Fix MIME type for CSS files (including extensionless ones in css/ directory)
if strings.Contains(r.URL.Path, "/css/") {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
}
fileServer.ServeHTTP(w, r)
})
router.Handle("/sites/"+siteID+"/*", http.StripPrefix("/sites/"+siteID+"/", customHandler))
// Handle both /sites/{siteID}/ and /{siteID}/ patterns
router.Mount(fmt.Sprintf("/sites/%s/", siteConfig.SiteID), http.StripPrefix(fmt.Sprintf("/sites/%s/", siteConfig.SiteID), fileServer))
// Optionally serve at root for primary site
if siteConfig.Domain != "" {
log.Printf("🌐 Site %s available at domain: %s", siteConfig.SiteID, siteConfig.Domain)
}
}
// Catch-all for serving sites by domain or default
router.NotFound(func(w http.ResponseWriter, r *http.Request) {
// Try to match by domain first
host := strings.Split(r.Host, ":")[0] // Remove port if present
for _, siteConfig := range siteManager.GetAllSites() {
if siteConfig.Domain == host {
fileServer := http.FileServer(http.Dir(siteConfig.Path))
fileServer.ServeHTTP(w, r)
return
}
}
// Default 404
http.NotFound(w, r)
})
// Start server
addr := fmt.Sprintf(":%d", port)
mode := "production"
if devMode {
mode = "development"
}
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
log.Printf("🚀 Server starting on %s", addr)
log.Printf("📝 Content API available at http://%s/api/content/{site_id}", addr)
log.Printf("🔐 Authentication at http://%s/auth/login", addr)
fmt.Printf("🚀 Insertr Content Server starting (%s mode)...\n", mode)
fmt.Printf("📁 Database: %s\n", dbPath)
fmt.Printf("🌐 Server running at: http://localhost%s\n", addr)
fmt.Printf("💚 Health check: http://localhost%s/health\n", addr)
fmt.Printf("📊 API endpoints:\n")
fmt.Printf(" Content:\n")
fmt.Printf(" GET /api/content?site_id={site}\n")
fmt.Printf(" GET /api/content/{id}?site_id={site}\n")
fmt.Printf(" GET /api/content/bulk?site_id={site}&ids[]={id1}&ids[]={id2}\n")
fmt.Printf(" POST /api/content\n")
fmt.Printf(" PUT /api/content/{id}\n")
fmt.Printf(" GET /api/content/{id}/versions?site_id={site}\n")
fmt.Printf(" POST /api/content/{id}/rollback\n")
fmt.Printf(" Collections:\n")
fmt.Printf(" GET /api/collections?site_id={site}\n")
fmt.Printf(" GET /api/collections/{id}?site_id={site}\n")
fmt.Printf(" GET /api/collections/{id}/items?site_id={site}\n")
fmt.Printf(" POST /api/collections/{id}/items\n")
fmt.Printf(" PUT /api/collections/{id}/items/{item_id}\n")
fmt.Printf(" DELETE /api/collections/{id}/items/{item_id}\n")
fmt.Printf(" PUT /api/collections/{id}/reorder\n")
fmt.Printf("🌐 Static sites:\n")
for siteID, _ := range siteManager.GetAllSites() {
fmt.Printf(" %s: http://localhost%s/sites/%s/\n", siteID, addr, siteID)
}
fmt.Printf("\n🔄 Press Ctrl+C to shutdown gracefully\n\n")
// Setup graceful shutdown
server := &http.Server{
Addr: addr,
Handler: router,
}
// Start server in a goroutine
// Graceful shutdown
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
log.Printf("🛑 Shutting down server...")
os.Exit(0)
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\n🛑 Shutting down server...")
if err := server.Close(); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
fmt.Println("✅ Server shutdown complete")
}