// Package bootstrap // Path: /internal/platform/bootstrap // File: loader.go // // Purpose // Centralized application initializer (the “application kernel”). // Constructs and wires the core runtime graph used by the system: // configuration, database, schema bootstrap, session manager (SCS), router (Gin), // CSRF wrapper (nosurf), and the HTTP server. // // Responsibilities (as implemented here) // 1) Load strongly-typed configuration from disk (config.Load). // 2) Open MySQL with pool tuning and DSN options (parseTime, utf8mb4, UTC). // 3) Ensure initial schema on an empty DB (databasePlatform.EnsureInitialSchema). // 4) Register gob types needed by sessions (map[string]string, []string, time.Time). // 5) Create an SCS session manager via platform/session.New. // 6) Build a Gin engine, attach global middleware, static mounts, and error handlers. // 7) Inject *App into Gin context (c.Set("app", app)) for handler access. // 8) Wrap Gin with SCS LoadAndSave, then wrap that with CSRF (nosurf). // 9) Construct http.Server with Handler and ReadHeaderTimeout. // // HTTP stack order (matches code) // Gin Router → SCS LoadAndSave → CSRF Wrapper → http.Server // // Design guarantees // - Single source of truth via the App struct (Config, DB, SessionManager, Router, Handler, Server). // - Stable middleware order: SCS wraps Gin before CSRF. // - Gin handlers can access *App via c.MustGet("app"). // - Error surfaces are unified via custom NoRoute/NoMethod/Recovery handlers. // - Extensible: add infra (cache/mailer/metrics) here. // // Operational details observed in this file // - MySQL DSN uses: parseTime=true, charset=utf8mb4, loc=UTC. // - Pool sizing and conn lifetime are read from config if set. // - Schema application is idempotent and runs on startup. // - Static files are served at /static and /favicon.ico. // - Logging uses gin.Logger(). Recovery uses gin.CustomRecovery with weberr.Recovery. // - ReadHeaderTimeout is set to 10s (currently hard-coded). // // Notes & TODOs (code-accurate) // - There’s a second DB ping: openMySQL() pings, and Load() pings again. // This is harmless but redundant; consider deleting one for clarity. // - gin.Recovery() is commented out; we use CustomRecovery instead (intentional). // - Consider moving ReadHeaderTimeout to config to match the rest of server tuning. // // Change log // [2025-10-28] Document EnsureInitialSchema bootstrap, explicit gob registrations, // clarified middleware/handler order, and server timeout behavior. // [2025-10-24] Migrated to SCS-first wrapping and explicit App wiring. package bootstrap import ( "context" "database/sql" "encoding/gob" "fmt" "net/http" "time" weberr "synlotto-website/internal/http/error" databasePlatform "synlotto-website/internal/platform/database" "synlotto-website/internal/platform/config" "synlotto-website/internal/platform/csrf" "synlotto-website/internal/platform/session" "github.com/alexedwards/scs/v2" "github.com/gin-gonic/gin" _ "github.com/go-sql-driver/mysql" ) type App struct { Config config.Config DB *sql.DB SessionManager *scs.SessionManager Router *gin.Engine Handler http.Handler Server *http.Server } func Load(configPath string) (*App, error) { // Load configuration cfg, err := config.Load(configPath) if err != nil { return nil, fmt.Errorf("load config: %w", err) } // Open DB db, err := openMySQL(cfg) if err != nil { return nil, err } // Ensure initial schema (idempotent; safe on restarts) if err := databasePlatform.EnsureInitialSchema(db); err != nil { return nil, fmt.Errorf("ensure schema: %w", err) } // Register gob types used in session values gob.Register(map[string]string{}) gob.Register([]string{}) gob.Register(time.Time{}) // Create SCS session manager sessions := session.New(cfg) // Build Gin router and global middleware router := gin.New() router.Use(gin.Logger()) router.Static("/static", "./web/static") router.StaticFile("/favicon.ico", "./web/static/favicon.ico") // Assemble App prior to injecting into context app := &App{ Config: cfg, DB: db, SessionManager: sessions, Router: router, } // Inject *App into Gin context for handler access router.Use(func(c *gin.Context) { c.Set("app", app) c.Next() }) // Error handling surfaces router.NoRoute(weberr.NoRoute(app.SessionManager)) router.NoMethod(weberr.NoMethod(app.SessionManager)) router.Use(gin.CustomRecovery(weberr.Recovery(app.SessionManager))) // Wrap: Gin → SCS → CSRF (nosurf) handler := sessions.LoadAndSave(router) handler = csrf.Wrap(handler, cfg) // 9) Build HTTP server addr := fmt.Sprintf("%s:%d", cfg.HttpServer.Address, cfg.HttpServer.Port) srv := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, // ToDo: consider moving to config } app.Handler = handler app.Server = srv return app, nil } func openMySQL(cfg config.Config) (*sql.DB, error) { dbCfg := cfg.Database // Credentials are used as-is; escaping handled by DSN rules for mysql driver. escapedUser := dbCfg.Username escapedPass := dbCfg.Password // DSN opts: parseTime=true, utf8mb4, UTC location dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4,utf8&loc=UTC", escapedUser, escapedPass, dbCfg.Server, dbCfg.Port, dbCfg.DatabaseNamed, ) db, err := sql.Open("mysql", dsn) if err != nil { return nil, fmt.Errorf("mysql open: %w", err) } // Pool tuning from config (optional) if dbCfg.MaxOpenConnections > 0 { db.SetMaxOpenConns(dbCfg.MaxOpenConnections) } if dbCfg.MaxIdleConnections > 0 { db.SetMaxIdleConns(dbCfg.MaxIdleConnections) } if dbCfg.ConnectionMaxLifetime != "" { if d, err := time.ParseDuration(dbCfg.ConnectionMaxLifetime); err == nil { db.SetConnMaxLifetime(d) } } // Connectivity check with timeout ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := db.PingContext(ctx); err != nil { _ = db.Close() return nil, fmt.Errorf("mysql ping: %w", err) } return db, nil }