224 lines
5.9 KiB
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)
|
|
}
|
|
}
|
|
}
|