package handlers import ( "bookra/apps/auth-service/internal/auth" "bookra/apps/auth-service/internal/billing" "bookra/apps/auth-service/internal/config" "bookra/apps/auth-service/internal/db" "bookra/apps/auth-service/internal/email" "bookra/apps/auth-service/internal/oauth" "context" "crypto/rand" "encoding/base64" "errors" "io" "log" "net/http" "net/url" "strings" "time" "github.com/gin-gonic/gin" ) type Handler struct { authSvc *auth.Service neon *auth.NeonVerifier billingSvc *billing.Service google *oauth.GoogleProvider cfg *config.Config db *db.DB } type LoginRequest struct { Email string `json:"email" binding:"required,email"` Locale string `json:"locale,omitempty"` } type VerifyRequest struct { Token string `json:"token" binding:"required"` } type RefreshRequest struct { RefreshToken string `json:"refresh_token"` } type PasswordRegisterRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=8"` Name string `json:"name" binding:"required"` } type PasswordLoginRequest struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required"` } type CheckoutRequest struct { PlanCode string `json:"planCode,omitempty"` Currency string `json:"currency,omitempty"` } func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) { neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL) if err != nil { return nil, err } return &Handler{ authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL), neon: neonVerifier, billingSvc: billing.NewService(cfg, db), google: oauth.NewGoogleProvider(cfg), cfg: cfg, db: db, }, nil } func (h *Handler) RegisterRoutes(r *gin.Engine) { // Auth API api := r.Group("/api/auth") { api.POST("/magic-link", h.SendMagicLink) api.POST("/verify", h.VerifyMagicLink) api.POST("/register", h.RegisterWithPassword) api.POST("/login", h.LoginWithPassword) api.POST("/refresh", h.RefreshToken) api.GET("/me", h.RequireAuth(), h.GetMe) api.POST("/logout", h.RequireAuth(), h.Logout) api.GET("/providers", h.ListProviders) api.GET("/oauth/google", h.GoogleAuth) api.GET("/oauth/google/callback", h.GoogleCallback) } // Billing API billingAPI := r.Group("/api/billing") { billingAPI.POST("/webhook", h.StripeWebhook) billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription) billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession) billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription) billingAPI.GET("/plans", h.ListPlans) } // Admin Dashboard (Visual Management) admin := NewAdminDashboard(h.cfg, h.db) admin.RegisterRoutes(r) } func (h *Handler) SendMagicLink(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Detect locale from request: JSON body > Accept-Language header > default "en" locale := req.Locale if locale == "" { locale = detectLocale(c) } if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil { log.Printf("magic link failed for %s: %v", req.Email, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"}) return } c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"}) } // detectLocale extracts locale from Accept-Language header func detectLocale(c *gin.Context) string { acceptLang := c.GetHeader("Accept-Language") if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") { return "cs" } // Default to English return "en" } func (h *Handler) VerifyMagicLink(c *gin.Context) { var req VerifyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) return } c.JSON(http.StatusOK, tokens) } func (h *Handler) RegisterWithPassword(c *gin.Context) { var req PasswordRegisterRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name) if err != nil { if strings.Contains(err.Error(), "already registered") { c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"}) return } c.JSON(http.StatusCreated, tokens) } func (h *Handler) LoginWithPassword(c *gin.Context) { var req PasswordLoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) return } c.JSON(http.StatusOK, tokens) } func (h *Handler) RefreshToken(c *gin.Context) { var req RefreshRequest if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } refreshToken := strings.TrimSpace(req.RefreshToken) if refreshToken == "" { authHeader := c.GetHeader("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) } } if refreshToken == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"}) return } tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"}) return } c.JSON(http.StatusOK, tokens) } func (h *Handler) GetMe(c *gin.Context) { claims, exists := c.Get("claims") if !exists { c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"}) return } userClaims := claims.(*auth.Claims) c.JSON(http.StatusOK, gin.H{ "id": userClaims.UserID, "email": userClaims.Email, "name": userClaims.Name, "role": userClaims.Role, }) } func (h *Handler) Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) } func (h *Handler) ListProviders(c *gin.Context) { providers := []gin.H{} if h.google.Enabled() { providers = append(providers, gin.H{ "id": "google", "name": "Google", "url": "/api/auth/oauth/google", }) } providers = append(providers, gin.H{ "id": "email", "name": "Email Magic Link", }) c.JSON(http.StatusOK, gin.H{"providers": providers}) } func (h *Handler) GoogleAuth(c *gin.Context) { if !h.google.Enabled() { c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"}) return } state := generateState() url := h.google.GetAuthURL(state) c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true) c.Redirect(http.StatusTemporaryRedirect, url) } func (h *Handler) GoogleCallback(c *gin.Context) { if !h.google.Enabled() { c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"}) return } state := c.Query("state") expectedState, err := c.Cookie("oauth_state") if err != nil || state == "" || state != expectedState { c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"}) return } c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true) code := c.Query("code") if code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"}) return } user, err := h.google.ExchangeCode(c.Request.Context(), code) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"}) return } providerID, email, name := h.google.ParseUser(user) tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"}) return } redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken) if tokens.RefreshToken != "" { redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken) } c.Redirect(http.StatusTemporaryRedirect, redirectURL) } func (h *Handler) GetSubscription(c *gin.Context) { claims, ok := h.claimsFromContext(c) if !ok { return } snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"}) return } c.JSON(http.StatusOK, snapshot) } func (h *Handler) CreateCheckoutSession(c *gin.Context) { claims, ok := h.claimsFromContext(c) if !ok { return } var req CheckoutRequest if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{ ID: claims.UserID, Email: claims.Email, Name: claims.Name, }, req.PlanCode, req.Currency) if err != nil { switch { case errors.Is(err, billing.ErrPlanNotConfigured): c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"}) case errors.Is(err, billing.ErrStripeNotConfigured): c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"}) } return } c.JSON(http.StatusOK, response) } func (h *Handler) RefreshSubscription(c *gin.Context) { claims, ok := h.claimsFromContext(c) if !ok { return } snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID) if err != nil { if errors.Is(err, billing.ErrStripeNotConfigured) { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"}) return } c.JSON(http.StatusOK, snapshot) } // ListPlans returns available billing plans and their configuration status func (h *Handler) ListPlans(c *gin.Context) { plans := []gin.H{ {"code": "starter", "name": "Starter", "description": "For individuals and small teams"}, {"code": "pro", "name": "Pro", "description": "For growing businesses"}, {"code": "business", "name": "Business", "description": "For multi-location operations"}, } // Check which plans are configured configured := make(map[string]bool) for planCode, priceID := range h.cfg.StripePriceIDs { if priceID != "" { configured[planCode] = true } } for i, plan := range plans { code := plan["code"].(string) plan["czkConfigured"] = configured[code+":czk"] || configured[code] plan["usdConfigured"] = configured[code+":usd"] || configured[code] plans[i] = plan } c.JSON(http.StatusOK, gin.H{ "plans": plans, "stripeConfigured": h.cfg.StripeCheckoutReady(), "secretConfigured": h.cfg.StripeSecretConfigured(), "webhookConfigured": h.cfg.StripeWebhookConfigured(), "pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(), "checkoutReady": h.cfg.StripeCheckoutReady(), "currencies": []string{"czk", "usd"}, }) } func (h *Handler) StripeWebhook(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) payload, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"}) return } if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil { switch { case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing): c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"}) } return } c.JSON(http.StatusOK, gin.H{"received": true}) } func (h *Handler) RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") tokenString := "" if strings.HasPrefix(authHeader, "Bearer ") { tokenString = strings.TrimPrefix(authHeader, "Bearer ") } if tokenString == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"}) c.Abort() return } claims, err := h.verifyBearerToken(tokenString) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.Abort() return } c.Set("claims", claims) c.Next() } } func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) { if h.neon != nil && h.neon.Enabled() { return h.neon.Verify(tokenString) } if h.cfg.AppEnv == "development" { return h.authSvc.VerifyToken(tokenString) } return nil, errors.New("neon auth is not configured") } func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) { claims, exists := c.Get("claims") if !exists { c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"}) return nil, false } userClaims, ok := claims.(*auth.Claims) if !ok { c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) return nil, false } return userClaims, true } func generateState() string { buffer := make([]byte, 24) if _, err := rand.Read(buffer); err != nil { return "state_" + time.Now().Format("20060102150405") } return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=") } func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool { if c.Request.TLS != nil { return true } if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { return true } return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://") } func timeoutMiddleware(duration time.Duration) gin.HandlerFunc { return func(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), duration) defer cancel() c.Request = c.Request.WithContext(ctx) c.Next() } }