package main import ( "bufio" "flag" "log" "os" "os/signal" "strconv" "strings" "syscall" "time" "server/internal/auth" "server/internal/config" "server/internal/db" "server/internal/http/controllers" "server/internal/http/routes" "server/internal/mail" "server/internal/roles" "server/internal/seed" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/cors" "github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/recover" "github.com/gofiber/fiber/v3/middleware/requestid" ) // Typescript: TSDeclaration= Nullable = T | null; // Typescript: TSDeclaration= Record = { [P in K]: T; } func main() { loadDotEnv(".env") config.TestRoleYAML() seedCount := flag.Int("seed", 0, "seed N fake users at startup (0 to skip)") flag.Parse() configPath := envOrDefault("CONFIG_PATH", "configs/config.json") cfg, err := config.LoadConfig(configPath) if err != nil { log.Fatalf("load config: %v", err) } if secret := os.Getenv("AUTH_SECRET"); secret != "" { cfg.Auth.Secret = secret } dbCfg := db.Config{ Driver: envOrDefault("DB_driver", "sqlite"), DSN: envOrDefault("DB_dsn", "file:./data/data.db?_foreign_keys=on"), } dbConn, err := db.Init(dbCfg) if err != nil { log.Fatalf("init db: %v", err) } authService, err := auth.New(auth.Config{ Secret: cfg.Auth.Secret, Issuer: cfg.Auth.Issuer, AccessTokenExpiry: time.Duration(cfg.Auth.AccessTokenExpiryMinutes) * time.Minute, RefreshTokenExpiry: time.Duration(cfg.Auth.RefreshTokenExpiryMinutes) * time.Minute, }) if err != nil { log.Fatalf("setup auth: %v", err) } mailService, err := mail.New(mail.Config{ AppName: cfg.AppName, Mode: cfg.Mail.Mode, From: cfg.Mail.From, DebugDir: cfg.Mail.DebugDir, TemplatesDir: cfg.Mail.TemplatesDir, FrontendBaseURL: cfg.Mail.FrontendBaseURL, ResetPasswordPath: cfg.Mail.ResetPasswordPath, SMTP: mail.SMTPConfig{ Host: cfg.Mail.SMTP.Host, Port: cfg.Mail.SMTP.Port, Username: cfg.Mail.SMTP.Username, Password: cfg.Mail.SMTP.Password, }, }) if err != nil { log.Fatalf("setup mail: %v", err) } roleConfigPath := cfg.RolesConfigPath if envRoleConfig := os.Getenv("ROLES_CONFIG_PATH"); envRoleConfig != "" { roleConfigPath = envRoleConfig } if roleConfigPath == "" { roleConfigPath = envOrDefault("ROLES_CONFIG_PATH", "configs/roles.json") } roleResolver, err := controllers.LoadRoleConfig(roleConfigPath) if err != nil { log.Fatalf("load role config: %v", err) } roles.CheckUserRoleConsistency(dbConn, roleResolver) app := fiber.New(fiber.Config{ AppName: cfg.AppName, ReadTimeout: time.Duration(cfg.ReadTimeoutSeconds) * time.Second, WriteTimeout: time.Duration(cfg.WriteTimeoutSeconds) * time.Second, IdleTimeout: time.Duration(cfg.IdleTimeoutSeconds) * time.Second, ErrorHandler: func(c fiber.Ctx, err error) error { code := fiber.StatusInternalServerError msg := "internal server error" if e, ok := err.(*fiber.Error); ok { code = e.Code msg = e.Message } reqID := requestid.FromContext(c) log.Printf("error request_id=%s status=%d method=%s path=%s ip=%s ua=%q err=%v", reqID, code, c.Method(), c.Path(), c.IP(), c.Get("User-Agent"), err) if code >= 500 { msg = "internal server error" } return c.Status(code).JSON(fiber.Map{"data": nil, "error": msg}) }, }) app.Use(requestid.New()) app.Use(recover.New()) app.Use(logger.New()) app.Use(cors.New(cors.Config{ AllowOrigins: []string{"http://localhost:9000"}, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Auth-Token", "cache-control", "Content-Type", "Accept", "Authorization"}, AllowCredentials: true, ExposeHeaders: []string{"Auth-Token"}, })) app.Options("/*", func(c fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) }) // Make the DB handle available in request context for future handlers. app.Use(func(c fiber.Ctx) error { c.Locals("db", dbConn) return c.Next() }) app.Use(controllers.RequireEndpointPermission(roleResolver, authService)) routes.Register(app, authService, mailService) port := envOrDefault("PORT", "3000") seedToRun := *seedCount if seedToRun <= 0 { seedToRun = envIntOrDefault("SEED", 0) } if seedToRun > 0 { _, creds, err := seed.SeedUsers(dbConn, seedToRun) if err != nil { log.Fatalf("seed users: %v", err) } for _, cred := range creds { log.Printf("seeded user email=%s password=%s", cred.Email, cred.Password) } } // Graceful shutdown so ctrl+c or SIGTERM stops the server cleanly. go func() { if err := app.Listen(":" + port); err != nil { log.Fatalf("server failed: %v", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Println("Shutting down server...") if err := app.Shutdown(); err != nil { log.Printf("shutdown error: %v", err) } } func envOrDefault(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback } func envIntOrDefault(key string, fallback int) int { value := os.Getenv(key) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { log.Printf("warning: invalid %s=%q, using default %d", key, value, fallback) return fallback } return parsed } func loadDotEnv(path string) { file, err := os.Open(path) if err != nil { return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if key == "" { continue } if _, exists := os.LookupEnv(key); !exists { _ = os.Setenv(key, value) } } }