mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
378 lines
11 KiB
Go
378 lines
11 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|