package main import ( "context" "log" "net/http" "os" "os/signal" "strconv" "strings" "path/filepath" "syscall" "time" "net/url" "fotbal-club/internal/config" "fotbal-club/internal/controllers" "fotbal-club/internal/middleware" "fotbal-club/internal/models" "fotbal-club/internal/routes" "fotbal-club/internal/services" db "fotbal-club/pkg/database" "fotbal-club/pkg/email" "fotbal-club/pkg/logger" "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" ) func main() { // Load configuration config.LoadConfig() // Set logger level based on DEBUG flag if config.AppConfig != nil && config.AppConfig.Debug { logger.SetLevel(logger.LevelDebug) log.Println("Logger level set to DEBUG") } else { logger.SetLevel(logger.LevelInfo) } // Gin mode: use release in production for performance if config.AppConfig != nil && config.AppConfig.AppEnv == "production" { gin.SetMode(gin.ReleaseMode) } // Normalize and ensure upload directory exists early uploadDir := config.AppConfig.UploadDir if strings.TrimSpace(uploadDir) == "" { uploadDir = "./uploads" } if abs, err := filepath.Abs(uploadDir); err == nil { uploadDir = abs } _ = os.MkdirAll(uploadDir, 0o755) config.AppConfig.UploadDir = uploadDir // Initialize database dbInstance, err := db.InitDB() if err != nil { log.Fatalf("Failed to connect to database: %v", err) } // Check if we should run migrations runMigrations, _ := strconv.ParseBool(os.Getenv("RUN_MIGRATIONS")) if runMigrations { if err := db.MigrateDB(dbInstance); err != nil { log.Fatalf("Failed to run database migrations: %v", err) } } // Best-effort: ensure schemas exist and are up-to-date (adds new columns) if err := dbInstance.AutoMigrate( &models.Settings{}, &models.User{}, &models.Article{}, &models.ScoreboardState{}, &models.CompetitionAlias{}, &models.Team{}, &models.Player{}, &models.ContactCategory{}, &models.Contact{}, &models.ContactMessage{}, &models.NewsletterSubscription{}, &models.Sponsor{}, &models.Clothing{}, &models.Poll{}, &models.PollOption{}, &models.PollVote{}, &models.NavigationItem{}, &models.SocialLink{}, &models.PageElementConfig{}, &models.ShortLink{}, &models.LinkClick{}, &models.Comment{}, &models.CommentReaction{}, &models.CommentBan{}, &models.UnbanRequest{}, &models.CommentReport{}, &models.UserProfile{}, &models.PointsTransaction{}, &models.Achievement{}, &models.UserAchievement{}, &models.RewardItem{}, &models.RewardRedemption{}, &models.MatchOverride{}, &models.TeamLogoOverride{}, &models.Sweepstake{}, &models.SweepstakePrize{}, &models.SweepstakeEntry{}, &models.SweepstakeWinner{}, &models.UploadedFile{}, &models.FileUsage{}, &models.ErrorEvent{}, ); err != nil { log.Printf("Warning: AutoMigrate failed: %v", err) } var settings models.Settings _ = dbInstance.First(&settings).Error // ignore not found if config.AppConfig != nil { if settings.ID != 0 { config.AppConfig.NewsletterEnabled = settings.NewsletterEnabled log.Printf("[startup] NewsletterEnabled=%v (from settings)", settings.NewsletterEnabled) } else { log.Printf("[startup] NewsletterEnabled=%v (from env default)", config.AppConfig.NewsletterEnabled) } // Auto-append FrontendBaseURL origin from settings to CORS AllowedOrigins if strings.TrimSpace(settings.FrontendBaseURL) != "" { if u, err := url.Parse(settings.FrontendBaseURL); err == nil && u.Scheme != "" && u.Host != "" { origin := u.Scheme + "://" + u.Host found := false for _, ao := range config.AppConfig.AllowedOrigins { if ao == origin { found = true break } } if !found { config.AppConfig.AllowedOrigins = append(config.AppConfig.AllowedOrigins, origin) log.Printf("[startup] Appended CORS allowed origin from settings: %s", origin) } } } } // Check if we should seed the database seedDatabase, _ := strconv.ParseBool(os.Getenv("SEED_DATABASE")) if seedDatabase { log.Println("Seeding database...") if err := db.SeedDB(dbInstance); err != nil { log.Printf("Warning: Failed to seed database: %v", err) } } // Initialize Gin router (custom stack) reporter := services.NewErrorReporter(config.AppConfig) r := gin.New() r.Use(middleware.CustomRecoveryWithReporter(reporter)) // Do not trust any proxies by default (prevent spoofed client IP) if err := r.SetTrustedProxies(nil); err != nil { log.Printf("Trusted proxies setup error: %v", err) } // Lightweight hardening middlewares r.Use(middleware.RequestID()) r.Use(middleware.RequestLogger()) r.Use(middleware.ErrorStatusReporter(reporter)) r.Use(middleware.SanitizeHeaders()) // Add database context with timeout to prevent hanging queries r.Use(middleware.DBContext()) // Limit non-upload request bodies (2MB) r.Use(middleware.RequestSizeLimit(2 * 1024 * 1024)) // Enforce JSON for mutating API calls (uploads exempt) r.Use(middleware.ValidateContentType()) // Set max multipart memory to match upload size limit (default is 32MB) r.MaxMultipartMemory = config.AppConfig.MaxUploadSize // Enable gzip compression for responses r.Use(gzip.Gzip(gzip.DefaultCompression)) // Apply strict security headers r.Use(middleware.SecurityHeaders()) // CORS only (security headers handled by middleware.SecurityHeaders) r.Use(func(c *gin.Context) { // CORS: reflect the Origin only if it is allowed. In development, also allow localhost/127.0.0.1 any port. origin := c.Request.Header.Get("Origin") allowed := false path := c.Request.URL.Path if strings.HasPrefix(path, "/api/v1/admin/scoreboard") || strings.HasPrefix(path, "/api/v1/scoreboard") { if origin != "" { allowed = true } } // 1) Explicit exact-origin allow list for _, ao := range config.AppConfig.AllowedOrigins { if ao == origin { allowed = true break } } // 2) Wildcard support: ALLOWED_ORIGINS="*" means reflect any non-empty Origin if !allowed { for _, ao := range config.AppConfig.AllowedOrigins { if ao == "*" && origin != "" { allowed = true break } } } // 3) If no ALLOWED_ORIGINS provided at all, reflect any non-empty Origin only in non-production if !allowed && len(config.AppConfig.AllowedOrigins) == 0 && origin != "" && config.AppConfig.AppEnv != "production" { allowed = true } if !allowed && origin != "" && config.AppConfig.AppEnv != "production" { // Relaxed rule for local dev, including common private LAN ranges if strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || strings.HasPrefix(origin, "https://localhost:") || strings.HasPrefix(origin, "https://127.0.0.1:") || strings.HasPrefix(origin, "http://192.168.") || strings.HasPrefix(origin, "https://192.168.") || strings.HasPrefix(origin, "http://10.") || strings.HasPrefix(origin, "https://10.") || strings.HasPrefix(origin, "http://172.") || strings.HasPrefix(origin, "https://172.") { allowed = true } } if allowed && origin != "" { c.Writer.Header().Set("Access-Control-Allow-Origin", origin) c.Writer.Header().Set("Vary", "Origin") c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") // Expose request id header for client-side diagnostics c.Writer.Header().Set("Access-Control-Expose-Headers", "X-Request-ID") } c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization, Cache-Control, X-Requested-With, X-Session-Token, X-Admin-Token, X-Dev-Admin, X-CSRF-Token") if c.Request.Header.Get("Access-Control-Request-Private-Network") != "" { c.Writer.Header().Set("Access-Control-Allow-Private-Network", "true") } if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) return } c.Next() }) // Set optimal caching for static assets and generated caches r.Use(middleware.AssetCacheControl()) // Setup API routes api := r.Group("/api/v1") // Setup other routes routes.SetupRoutes(api, dbInstance) routes.SetupRootRoutes(r, dbInstance) // Prometheus metrics endpoint r.GET("/metrics", gin.WrapH(promhttp.Handler())) // Create an email service instance for background jobs (uses DB-backed SMTP settings) emailSvc := email.NewEmailService(config.AppConfig, dbInstance) // Expose cached JSON files for the frontend at /cache/* r.Static("/cache", "./cache") // Serve static assets and uploads // Map /dist to ./static to expose files like /dist/img/logo-club-empty.svg r.Static("/dist", "./static") r.Static("/uploads", config.AppConfig.UploadDir) // Serve premium asset pack (CSS/JS) for cloned pro pages r.Static("/premium-assets", "./pro") // Ensure gallery flat files exist at startup (best effort) _ = services.RegenerateFlatGalleryFiles() // Start background prefetcher (fetches public endpoints to cache JSON every 30 minutes) // It targets this server's own public API. Allow override via PREFETCH_TARGET (useful in container/proxy setups). prefetchTarget := os.Getenv("PREFETCH_TARGET") if prefetchTarget == "" { prefetchTarget = "http://127.0.0.1:" + config.AppConfig.Port + "/api/v1" } services.StartPrefetcher(prefetchTarget) services.StartErrorReviewAutoRegister(dbInstance) // Start newsletter scheduler (automated emails - legacy weekly) services.StartNewsletterScheduler(dbInstance, emailSvc) // Start sweepstakes scheduler (finalizes and picks winners at end time) services.StartSweepstakesScheduler(dbInstance, emailSvc) // Start comprehensive newsletter automation (weekly, match alerts, blog notifications, results) newsletterAutomation := services.NewNewsletterAutomation(dbInstance, emailSvc) newsletterAutomation.Start() // Store newsletter automation instance for use in controllers controllers.SetNewsletterAutomation(newsletterAutomation) // Start server with graceful shutdown port := config.AppConfig.Port // Fail fast in production if JWT secret is left as default if config.AppConfig.AppEnv == "production" && config.AppConfig.JWTSecret == "default-secret-key-change-in-production" { log.Fatalf("SECURITY: JWT_SECRET is using the default value in production. Please set a strong secret.") } srv := &http.Server{ Addr: ":" + port, Handler: r, ReadTimeout: config.AppConfig.ReadTimeout, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: config.AppConfig.WriteTimeout, IdleTimeout: config.AppConfig.IdleTimeout, } // DB handle for closing on shutdown sqlDB, err := dbInstance.DB() if err != nil { log.Printf("Error getting database connection: %v", err) } // Start server in background go func() { log.Printf("Server starting on port %s\n", port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Failed to start server: %v", err) } }() // Wait for interrupt signal to gracefully shutdown the server quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Println("Shutdown signal received, shutting down server...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("Server forced to shutdown: %v", err) } if sqlDB != nil { if err := sqlDB.Close(); err != nil { log.Printf("Error closing database connection: %v", err) } } }