mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
312 lines
10 KiB
Go
312 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
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
|
|
|
|
// Favor faster compression to reduce server CPU under load.
|
|
r.Use(gzip.Gzip(gzip.BestSpeed))
|
|
|
|
// Apply strict security headers
|
|
r.Use(middleware.SecurityHeaders())
|
|
|
|
// Add i18n middleware for language detection and context
|
|
i18nMiddleware, err := middleware.NewI18nMiddleware()
|
|
if err != nil {
|
|
log.Printf("Warning: Failed to initialize i18n middleware: %v", err)
|
|
} else {
|
|
r.Use(i18nMiddleware.Middleware())
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Initialize and start directory heartbeat service
|
|
directoryController := controllers.NewDirectoryController(dbInstance)
|
|
directoryController.StartDirectoryHeartbeat()
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|