go-quasar-partial-ssr/backend/cmd/server/main.go

224 lines
5.9 KiB
Go

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> = T | null;
// Typescript: TSDeclaration= Record<K extends string | number | symbol, T> = { [P in K]: T; }
func main() {
loadDotEnv(".env")
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)
}
}
}