mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
cleanup
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user