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:
@@ -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 {
|
||||
// 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
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
db.Save(&user)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
// Product endpoints (full CRUD)
|
||||
r.Get("/products", productHandler.GetProducts) // Public
|
||||
r.Get("/products/{id}", productHandler.GetProduct) // Public
|
||||
// Public product listing
|
||||
r.Get("/products", productHandler.GetProducts)
|
||||
r.Get("/products/{id}", productHandler.GetProduct)
|
||||
|
||||
// Protected routes - requires a valid JWT
|
||||
// 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) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@@ -11,19 +14,21 @@ import (
|
||||
type Config struct {
|
||||
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)),
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user