Files
Tomas Dvorak 30d70a6aeb update
2026-03-13 14:34:19 +01:00

378 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"fotbal-club/internal/config"
eshop_controllers "fotbal-club/internal/controllers/eshop"
"fotbal-club/internal/middleware"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/database"
"fotbal-club/pkg/email"
"fotbal-club/pkg/logger"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func main() {
// Load shared configuration
config.LoadConfig()
// Logger level based on DEBUG flag
if config.AppConfig != nil && config.AppConfig.Debug {
logger.SetLevel(logger.LevelDebug)
log.Println("[eshop] Logger level set to DEBUG")
} else {
logger.SetLevel(logger.LevelInfo)
}
// Gin mode
if config.AppConfig != nil && config.AppConfig.AppEnv == "production" {
gin.SetMode(gin.ReleaseMode)
}
// Initialize shared database connection
dbInstance, err := database.InitDB()
if err != nil {
log.Fatalf("[eshop] Failed to connect to database: %v", err)
}
// Optional migrations for eshop-specific tables only (will be added later)
runMigrations, _ := strconv.ParseBool(os.Getenv("RUN_MIGRATIONS"))
if runMigrations {
if err := database.MigrateDB(dbInstance); err != nil {
log.Fatalf("[eshop] Failed to run database migrations: %v", err)
}
}
// Initialize Gin router with a similar hardened stack as the main backend
reporter := services.NewErrorReporter(config.AppConfig)
r := gin.New()
r.Use(middleware.CustomRecoveryWithReporter(reporter))
if err := r.SetTrustedProxies(nil); err != nil {
log.Printf("[eshop] Trusted proxies setup error: %v", err)
}
r.Use(middleware.RequestID())
r.Use(middleware.RequestLogger())
r.Use(middleware.ErrorStatusReporter(reporter))
r.Use(middleware.SanitizeHeaders())
r.Use(middleware.DBContext())
r.Use(middleware.RequestSizeLimit(2 * 1024 * 1024))
r.Use(middleware.ValidateContentType())
r.Use(gzip.Gzip(gzip.DefaultCompression))
r.Use(middleware.SecurityHeaders())
// E-shop CORS reuse AllowedOrigins from shared config
r.Use(func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
allowed := false
// Explicit exact-origin allow list
for _, ao := range config.AppConfig.AllowedOrigins {
if ao == origin {
allowed = true
break
}
}
// Wildcard support
if !allowed {
for _, ao := range config.AppConfig.AllowedOrigins {
if ao == "*" && origin != "" {
allowed = true
break
}
}
}
// Relaxed rule for non-production local dev
if !allowed && origin != "" && config.AppConfig.AppEnv != "production" {
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:") {
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")
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.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// Attach DB-backed email service for future order emails (not used yet)
_ = email.NewEmailService(config.AppConfig, dbInstance)
// API group for E-shop
api := r.Group("/api/v1/eshop")
// Healthcheck
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "myclub-eshop-backend",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
api.HEAD("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"service": "myclub-eshop-backend",
"time": time.Now().UTC().Format(time.RFC3339),
})
})
// Admin Setup & Management Endpoints
admin := api.Group("/admin")
admin.Use(middleware.JWTAuth(dbInstance))
admin.Use(middleware.RoleAuth("admin"))
{
// GET /api/v1/eshop/admin/settings
admin.GET("/settings", func(c *gin.Context) {
var settings models.EshopSettings
if err := dbInstance.First(&settings).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Return defaults if not found
c.JSON(http.StatusOK, models.EshopSettings{
DefaultCurrency: "CZK",
DefaultCountry: "CZ",
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load settings"})
return
}
c.JSON(http.StatusOK, settings)
})
// PUT /api/v1/eshop/admin/settings
admin.PUT("/settings", func(c *gin.Context) {
var body models.EshopSettings
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
var settings models.EshopSettings
if err := dbInstance.First(&settings).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create new
settings = body
// Ensure ID is 0 or handled by GORM for creation
settings.ID = 0
if err := dbInstance.Create(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create settings"})
return
}
c.JSON(http.StatusOK, settings)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing settings"})
return
}
// Update existing
settings.DefaultCurrency = body.DefaultCurrency
settings.SupportedCurrencies = body.SupportedCurrencies
settings.DefaultCountry = body.DefaultCountry
settings.ShippingOptionsJSON = body.ShippingOptionsJSON
settings.TermsURL = body.TermsURL
settings.ReturnsPolicyURL = body.ReturnsPolicyURL
settings.SupportEmail = body.SupportEmail
settings.SupportPhone = body.SupportPhone
if err := dbInstance.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}
c.JSON(http.StatusOK, settings)
})
// GET /api/v1/eshop/admin/club-info
// Returns main Club Settings to pre-fill E-shop Setup
admin.GET("/club-info", func(c *gin.Context) {
var clubSettings models.Settings
if err := dbInstance.First(&clubSettings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load club settings"})
return
}
c.JSON(http.StatusOK, gin.H{
"club_name": clubSettings.ClubName,
"club_logo_url": clubSettings.ClubLogoURL,
"contact_email": clubSettings.ContactEmail,
"contact_phone": clubSettings.ContactPhone,
"contact_country": clubSettings.ContactCountry,
"primary_color": clubSettings.PrimaryColor,
})
})
}
// Shipping controller & endpoints
shippingCtrl := eshop_controllers.NewShippingController(config.AppConfig)
// Admin Shipping Actions
admin.POST("/orders/:id/create-packet", shippingCtrl.CreatePacket)
// Public Shipping endpoints
shipping := api.Group("/shipping")
{
shipping.GET("/packeta-widget-config", shippingCtrl.GetPacketaWidgetConfig)
shipping.GET("/labels/:packet_id", shippingCtrl.DownloadLabel)
}
// Support chat routes
support := api.Group("/support")
support.Use(middleware.JWTOptional(dbInstance), middleware.RateLimit(60, time.Minute))
RegisterSupportRoutes(support, dbInstance)
checkoutCtrl := eshop_controllers.NewCheckoutController(dbInstance, config.AppConfig)
api.POST("/checkout", middleware.JWTOptional(dbInstance), middleware.RateLimit(10, time.Minute), checkoutCtrl.Checkout)
// Payment webhooks (Revolut, Stripe)
payments := api.Group("/payments")
{
payments.POST("/revolut/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.RevolutWebhook)
payments.POST("/stripe/webhook", middleware.RateLimit(60, time.Minute), checkoutCtrl.StripeWebhook)
}
// Background jobs
go func() {
// Update packet statuses every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
if config.AppConfig.PacketaAPIPassword != "" {
shippingCtrl.UpdatePacketStatuses(dbInstance)
}
}
}()
// Public catalog endpoints (auth optional)
productsPub := api.Group("/products")
productsPub.Use(middleware.JWTOptional(dbInstance))
{
// GET /api/v1/eshop/products
productsPub.GET("", func(c *gin.Context) {
var items []models.EshopProduct
q := dbInstance.
Preload("Category").
Preload("Variants").
Where("active = ?", true).
Order("created_at DESC, id DESC")
if err := q.Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load products"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
})
// GET /api/v1/eshop/products/:slug
productsPub.GET(":slug", func(c *gin.Context) {
slug := c.Param("slug")
if strings.TrimSpace(slug) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing product slug"})
return
}
var item models.EshopProduct
if err := dbInstance.
Preload("Category").
Preload("Variants").
Where("slug = ? AND active = ?", slug, true).
First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
return
}
c.JSON(http.StatusOK, item)
})
}
// Cart controller
cartCtrl := eshop_controllers.NewCartController(dbInstance, config.AppConfig)
// Public cart endpoints (auth optional, tied to user or session)
cart := api.Group("/cart")
cart.Use(middleware.JWTOptional(dbInstance))
{
cart.GET("", cartCtrl.GetCart)
cart.POST("/items", cartCtrl.AddItem)
cart.PATCH("/items/:id", cartCtrl.UpdateItem)
cart.DELETE("/items/:id", cartCtrl.RemoveItem)
}
// GET /api/v1/eshop/orders/:id
api.GET("/orders/:id", middleware.JWTOptional(dbInstance), checkoutCtrl.GetOrder)
port := os.Getenv("PORT")
if strings.TrimSpace(port) == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: r,
ReadTimeout: config.AppConfig.ReadTimeout,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: config.AppConfig.WriteTimeout,
IdleTimeout: config.AppConfig.IdleTimeout,
}
// Graceful shutdown and DB close
sqlDB, err := dbInstance.DB()
if err != nil {
log.Printf("[eshop] Error getting database connection: %v", err)
}
go func() {
log.Printf("[eshop] Server starting on port %s\n", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("[eshop] Failed to start server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("[eshop] 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("[eshop] Server forced to shutdown: %v", err)
}
if sqlDB != nil {
if err := sqlDB.Close(); err != nil {
log.Printf("[eshop] Error closing database connection: %v", err)
}
}
}