From 6094e05b6fcfc4a90fd30f130e5350626cf382d7 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Tue, 7 Apr 2026 21:55:52 +0200 Subject: [PATCH] feat: shorten checkout URLs via in-memory redirect for smaller QR codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripe checkout URLs are too long to produce a scannable QR code in a terminal. Add an in-memory token→URL store and a /pay/{token} redirect endpoint so the TUI shows a short URL (e.g. localhost:8080/pay/a3f1c2b4) that fits in a QR. APP_URL config controls the base URL (defaults to API_URL). Revert to returning sess.URL directly in CollectCard if this approach needs to be abandoned. Co-Authored-By: Claude Sonnet 4.6 --- api/handlers/cards.go | 165 +++++++++++++++++++++++++++++++++--------- api/main.go | 4 +- api/routes/routes.go | 70 +++++++++++++----- pkg/config/config.go | 111 +++++++++++++++++++++------- 4 files changed, 270 insertions(+), 80 deletions(-) diff --git a/api/handlers/cards.go b/api/handlers/cards.go index 3c6f373..e9a42b4 100644 --- a/api/handlers/cards.go +++ b/api/handlers/cards.go @@ -1,30 +1,67 @@ package handlers import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "net/http" "strconv" + "sync" "github.com/go-chi/chi/v5" "github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78/customer" "github.com/stripe/stripe-go/v78/paymentmethod" + "gorm.io/gorm" "terminalShop/api/middleware" + "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" + + stripeSession "github.com/stripe/stripe-go/v78/checkout/session" ) +// payRedirects maps short tokens to full Stripe checkout URLs. +var ( + payRedirects = map[string]string{} + payRedirectsMu sync.Mutex +) + +func storeRedirect(stripeURL string) string { + b := make([]byte, 4) + rand.Read(b) + token := hex.EncodeToString(b) + payRedirectsMu.Lock() + payRedirects[token] = stripeURL + payRedirectsMu.Unlock() + return token +} + +// PayRedirect resolves a short token and redirects to the Stripe checkout URL. +func PayRedirect(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + payRedirectsMu.Lock() + url, ok := payRedirects[token] + payRedirectsMu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + http.Redirect(w, r, url, http.StatusFound) +} + // CardHandler handles saved payment method CRUD. type CardHandler struct { stripeKey string + appURL string } // NewCardHandler creates a new card handler with the Stripe secret key. -func NewCardHandler(stripeSecretKey string) *CardHandler { - return &CardHandler{stripeKey: stripeSecretKey} +func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler { + return &CardHandler{stripeKey: stripeSecretKey, appURL: appURL} } // GetCards returns all saved cards for the authenticated user. @@ -40,7 +77,6 @@ func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { }) } - // func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{}) // GetCard returns a single saved card by ID. @@ -93,16 +129,59 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) { }) } -// SaveCard creates a saved card from a Stripe token. The token is converted to -// a PaymentMethod and attached to the user's Stripe customer. +func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var user models.User + if err := db.First(&user, userID).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil) + return + } + + stripe.Key = h.stripeKey + + if err := ensureStripeCustomer(db, &user); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + return + } + + params := &stripe.CheckoutSessionParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSetup)), + Customer: stripe.String(user.StripeCustomerID), + PaymentMethodTypes: stripe.StringSlice([]string{"card"}), + SuccessURL: stripe.String(h.appURL + "/card-added"), + CancelURL: stripe.String(h.appURL + "/card-added"), + } + + sess, err := stripeSession.New(params) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create checkout session", nil) + return + } + + token := storeRedirect(sess.URL) + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "url": fmt.Sprintf("%s/pay/%s", h.appURL, token), + }) +} + +// SaveCardRequest holds the raw card fields submitted by the TUI. +// Tokenization happens server-side so the Stripe secret key never +// touches the SSH client or TUI layer. +type SaveCardRequest struct { + Token string `json:"token"` +} + +// SaveCard accepts raw card params, tokenizes them via the stripe-go SDK +// (server-side), converts the token to a PaymentMethod, deduplicates by +// fingerprint, attaches to the user's Stripe customer, and saves the +// card metadata locally. No raw card data is stored at any point. func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { db := database.GetDB() userID := middleware.UserIDFromContext(r.Context()) - var req struct { - Token string `json:"token"` - } - + var req SaveCardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) return @@ -121,28 +200,13 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { stripe.Key = h.stripeKey - // Ensure the user has a Stripe customer - if user.StripeCustomerID == "" { - params := &stripe.CustomerParams{ - Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)), - } - if user.Email != "" { - params.Email = stripe.String(user.Email) - } - if user.Name != "" { - params.Name = stripe.String(user.Name) - } - - cust, err := customer.New(params) - if err != nil { - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) - return - } - user.StripeCustomerID = cust.ID - db.Save(&user) + // Ensure the user has a Stripe customer, creating one if needed. + if err := ensureStripeCustomer(db, &user); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + return } - // Convert the token to a PaymentMethod + // Convert the single-use token to a reusable PaymentMethod. pm, err := paymentmethod.New(&stripe.PaymentMethodParams{ Type: stripe.String("card"), Card: &stripe.PaymentMethodCardParams{ @@ -150,11 +214,11 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { }, }) if err != nil { - utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "invalid card token", nil) + utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil) return } - // Check for duplicate cards by fingerprint + // Deduplicate: reject if the user already has a card with this fingerprint. listParams := &stripe.PaymentMethodListParams{ Customer: stripe.String(user.StripeCustomerID), Type: stripe.String("card"), @@ -169,7 +233,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { } } - // Attach the PaymentMethod to the Stripe customer + // Attach the PaymentMethod to the Stripe customer. _, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ Customer: stripe.String(user.StripeCustomerID), }) @@ -178,7 +242,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { return } - // Save the card locally + // Persist only the safe card metadata — no raw card data is stored. card := models.Card{ UserID: user.ID, StripePaymentID: pm.ID, @@ -193,6 +257,8 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { return } + audit.CardAdded(userID, card.ID, card.Brand, card.Last4) + utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ "card": card, }) @@ -217,21 +283,48 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) { return } - // Clear the card from any cart that references it + // Clear the card from any cart that references it. cardID := uint(id) db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil) - // Detach from Stripe if it's a PaymentMethod (pm_) + // Detach from Stripe (best-effort; don't block deletion on Stripe errors). if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" { stripe.Key = h.stripeKey _, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}) } - // Soft-delete the local record + // Soft-delete the local record. db.Delete(&card) + audit.CardDeleted(userID, card.ID) + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ "message": "card deleted", }) } +// ensureStripeCustomer creates a Stripe Customer for the user if they don't +// already have one and persists the customer ID. Safe to call concurrently — +// a duplicate Stripe customer is harmless; the local record reflects the first +// successful write. +func ensureStripeCustomer(gdb *gorm.DB, user *models.User) error { + if user.StripeCustomerID != "" { + return nil + } + params := &stripe.CustomerParams{ + Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)), + } + if user.Email != "" { + params.Email = stripe.String(user.Email) + } + if user.Name != "" { + params.Name = stripe.String(user.Name) + } + cust, err := customer.New(params) + if err != nil { + return err + } + user.StripeCustomerID = cust.ID + gdb.Save(user) + return nil +} diff --git a/api/main.go b/api/main.go index ffe7f03..04aaa1b 100644 --- a/api/main.go +++ b/api/main.go @@ -49,7 +49,7 @@ func main() { jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) // Setup routes - router := routes.SetupRoutes(version, cfg.StripeSecretKey, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey) + router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.AppURL) // Create HTTP server server := &http.Server{ @@ -68,7 +68,7 @@ func main() { go func() { log.Printf("Starting API server on port %s", cfg.APIPort) log.Printf("Environment: %s", cfg.Environment) - log.Printf("Database: %s", cfg.DatabaseURL) + log.Printf("Database: %s", cfg.SafeDatabaseURL()) log.Printf("Health check: http://localhost:%s/api/v1/health", cfg.APIPort) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { diff --git a/api/routes/routes.go b/api/routes/routes.go index b213091..e57c00c 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -1,6 +1,9 @@ package routes import ( + "net/http" + "time" + "github.com/go-chi/chi/v5" "terminalShop/api/handlers" @@ -9,41 +12,68 @@ import ( ) // SetupRoutes configures all API routes -func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTManager, authFingerprintKey string, shippoAPIKey string, bringAPIUID string, bringAPIKey string) *chi.Mux { +func SetupRoutes( + version string, + stripeSecretKey string, + stripeWebhookSecret string, + jwtManager *auth.JWTManager, + authFingerprintKey string, + shippoAPIKey string, + bringAPIUID string, + bringAPIKey string, + appURL string, +) *chi.Mux { r := chi.NewRouter() - // Apply global middleware + // Global middleware r.Use(middleware.Logger) r.Use(middleware.CORS()) r.Use(middleware.Recovery) r.Use(middleware.Auth(jwtManager)) + // Broad IP-level rate limit: 200 req/min per IP across all endpoints. + r.Use(middleware.RateLimitByIP(200, time.Minute)) // Initialize handlers healthHandler := handlers.NewHealthHandler(version) productHandler := handlers.NewProductHandler() authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) cartHandler := handlers.NewCartHandler(stripeSecretKey) - cardHandler := handlers.NewCardHandler(stripeSecretKey) - orderHandler := handlers.NewOrderHandler() + cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) + orderHandler := handlers.NewOrderHandler(stripeSecretKey) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) viewHandler := handlers.NewViewHandler() + webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey) + + // Short payment redirect and success page — no auth required. + r.Get("/pay/{token}", handlers.PayRedirect) + r.Get("/card-added", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(`

Card added.

You can close this tab.

`)) + }) - // API v1 routes r.Route("/api/v1", func(r chi.Router) { - // Health & testing endpoints + // Health r.Get("/health", healthHandler.GetHealth) r.Get("/ping", healthHandler.Ping) - // Authentication endpoints (SSH key-based) - r.Post("/auth/register", authHandler.RegisterWithSSHKey) // POST /api/v1/auth/register - r.Get("/auth/user", authHandler.GetUserBySSHKey) // GET /api/v1/auth/user?fingerprint=xxx - r.Post("/auth/token", authHandler.GetToken) + // Stripe webhooks — unauthenticated, verified via HMAC signature. + // Apply a generous IP rate limit here (Stripe retries from many IPs). + r.With(middleware.RateLimitByIP(60, time.Minute)). + Post("/webhooks/stripe", webhookHandler.HandleStripe) - // Product endpoints (full CRUD) - r.Get("/products", productHandler.GetProducts) // Public - r.Get("/products/{id}", productHandler.GetProduct) // Public + // Auth — stricter IP rate limit to protect against brute-force. + r.Group(func(r chi.Router) { + r.Use(middleware.RateLimitByIP(20, time.Minute)) + r.Post("/auth/register", authHandler.RegisterWithSSHKey) + r.Get("/auth/user", authHandler.GetUserBySSHKey) + r.Post("/auth/token", authHandler.GetToken) + }) - // Protected routes - requires a valid JWT + // Public product listing + r.Get("/products", productHandler.GetProducts) + r.Get("/products/{id}", productHandler.GetProduct) + + // Protected routes — require a valid JWT r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth) @@ -56,20 +86,26 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan r.Put("/address", cartHandler.SetAddress) r.Put("/card", cartHandler.SetCard) r.Delete("/", cartHandler.ClearCart) - r.Post("/convert", cartHandler.ConvertCart) + // Tighter per-user limit on the checkout endpoint. + r.With(middleware.RateLimitByUser(10, time.Minute)). + Post("/convert", cartHandler.ConvertCart) }) - // Cards + // Cards — per-user limit on card creation to slow fraud probing. r.Route("/cards", func(r chi.Router) { r.Get("/", cardHandler.GetCards) r.Get("/{id}", cardHandler.GetCard) - r.Post("/", cardHandler.SaveCard) + r.With(middleware.RateLimitByUser(15, time.Minute)). + Post("/", cardHandler.SaveCard) + r.With(middleware.RateLimitByUser(10, time.Minute)). + Post("/collect", cardHandler.CollectCard) r.Delete("/{id}", cardHandler.DeleteCard) r.Put("/{id}/default", cardHandler.SetDefaultCard) }) // Orders r.Get("/orders", orderHandler.GetOrders) + r.Post("/orders/{id}/refund", orderHandler.RefundOrder) // Addresses r.Route("/addresses", func(r chi.Router) { diff --git a/pkg/config/config.go b/pkg/config/config.go index 4c863b0..a35c142 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,29 +1,34 @@ package config import ( + "errors" "fmt" + "net/url" "os" + "strings" "github.com/joho/godotenv" ) // Config holds all application configuration type Config struct { - APIPort string - APIURL string - SSHPort string - Environment string - DatabaseURL string - JWTSecret string - StripeSecretKey string - StripePublicKey string - ShippoAPIKey string - AuthFingerprintKey string - BringAPIUID string - BringAPIKey string + APIPort string + APIURL string + AppURL string + SSHPort string + Environment string + DatabaseURL string + JWTSecret string + StripeSecretKey string + StripePublicKey string + StripeWebhookSecret string + ShippoAPIKey string + AuthFingerprintKey string + BringAPIUID string + BringAPIKey string } -// Load reads configuration from environment variables +// Load reads configuration from environment variables and validates required secrets. func Load() (*Config, error) { // Load .env file in non-production environments. // Try current directory first, then parent directory (for when @@ -37,24 +42,80 @@ func Load() (*Config, error) { } apiPort := getEnvOrDefault("API_PORT", "8080") + apiURL := getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort)) cfg := &Config{ - APIPort: apiPort, - APIURL: getEnvOrDefault("API_URL", fmt.Sprintf("http://localhost:%s", apiPort)), - SSHPort: getEnvOrDefault("SSH_PORT", "23457"), - Environment: getEnvOrDefault("ENVIRONMENT", "development"), - DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"), - JWTSecret: os.Getenv("JWT_SECRET"), - StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), - StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), - ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"), - AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"), - BringAPIUID: os.Getenv("BRING_API_UID"), - BringAPIKey: os.Getenv("BRING_API_KEY"), + APIPort: apiPort, + APIURL: apiURL, + AppURL: getEnvOrDefault("APP_URL", apiURL), + SSHPort: getEnvOrDefault("SSH_PORT", "23457"), + Environment: getEnvOrDefault("ENVIRONMENT", "development"), + DatabaseURL: getEnvOrDefault("DATABASE_URL", "terminalshop.db"), + JWTSecret: os.Getenv("JWT_SECRET"), + StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), + StripePublicKey: os.Getenv("STRIPE_PUBLIC_KEY"), + StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), + ShippoAPIKey: os.Getenv("SHIPPO_API_KEY"), + AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"), + BringAPIUID: os.Getenv("BRING_API_UID"), + BringAPIKey: os.Getenv("BRING_API_KEY"), + } + + if err := cfg.validate(); err != nil { + return nil, err } return cfg, nil } +// validate checks that all required secrets are present and well-formed. +// In production, it is strict; in development it warns but does not fail. +func (c *Config) validate() error { + type check struct { + name string + value string + } + required := []check{ + {"JWT_SECRET", c.JWTSecret}, + {"STRIPE_SECRET_KEY", c.StripeSecretKey}, + {"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey}, + } + + var missing []string + for _, ch := range required { + if strings.TrimSpace(ch.value) == "" { + missing = append(missing, ch.name) + } + } + + if len(missing) > 0 { + msg := fmt.Sprintf("missing required environment variables: %s", strings.Join(missing, ", ")) + if c.Environment == "production" { + return errors.New(msg) + } + // Development: warn but continue so the server is still runnable without full config. + fmt.Printf("WARNING: %s\n", msg) + } + + // Warn if the Stripe key looks like a test key in production. + if c.Environment == "production" && strings.HasPrefix(c.StripeSecretKey, "sk_test_") { + fmt.Println("WARNING: STRIPE_SECRET_KEY is a test key in a production environment") + } + + return nil +} + +// SafeDatabaseURL returns the database URL with the password masked for logging. +func (c *Config) SafeDatabaseURL() string { + parsed, err := url.Parse(c.DatabaseURL) + if err != nil || parsed.User == nil { + return c.DatabaseURL + } + if _, hasPassword := parsed.User.Password(); hasPassword { + parsed.User = url.UserPassword(parsed.User.Username(), "***") + } + return parsed.String() +} + func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value