feat: shorten checkout URLs via in-memory redirect for smaller QR codes

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:55:52 +02:00
parent a4d5a1559a
commit 6094e05b6f
4 changed files with 270 additions and 80 deletions

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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(`<!DOCTYPE html><html><body style="font-family:monospace;text-align:center;padding:4rem"><h2>Card added.</h2><p>You can close this tab.</p></body></html>`))
})
// 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) {

View File

@@ -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