Files
guestguard/internal/config/config.go
T
Kwaku Danso e5b187c575 feat(tier2): event branding + UX polish — Block D
Backend
- Migration 0010 adds event_branding (one row per event; all fields
  nullable so a brand-new event renders with defaults)
- BrandingRepo with COALESCE/NULLIF upsert semantics: nil pointer
  preserves the existing value, "" clears the field to NULL
- internal/uploads package: ImageStore interface + LocalFSStore (dev),
  pure-stdlib decode + re-encode that strips EXIF and rejects anything
  that isn't valid JPEG/PNG. Size cap 2 MB, random 16-byte filenames
- GET /events/{id}/branding (viewer+) returns the row plus the
  AllowedFonts list so the frontend picker stays in sync
- PUT /events/{id}/branding (editor+) validates hex colours, font
  allowlist, and refuses image URLs whose path doesn't start with
  /uploads/ (blocks arbitrary-origin <img> smuggling on guest pages)
- POST /uploads/image (authed) → fresh CDN URL; GET /uploads/{file}
  serves with year-long cache (immutable random names)
- GET /access/{token} now embeds the host's branding so the RSVP page
  can render in their colours/font with their logo + cover
- docker-compose mounts a named volume for uploads
- Custom-domain sub-block deferred to Tier 3 per the plan

Frontend
- BrandingCard.vue: colour pickers, font dropdown, logo + cover upload
  with progressive disclosure, live preview pane that re-renders on
  every keystroke
- RSVP page applies branding via CSS vars at the section root, so
  primary colour theme + font cascade through every child card. Cover
  image renders as a banner above the form; logo lands in the header
- Submit button background switches to var(--brand-primary) when set
- Mounted on the event detail page below the guests block

Plus the small UX fixes from the e2e walkthrough:
- Nav: dropped the top-level "Events" link; the logo doubles as the
  home affordance (→ /dashboard when signed in, → / otherwise). Account
  + Billing + Sign out live under a profile dropdown (avatar with
  initials, opens on click, closes on outside-click / Esc / route nav)
- Renamed "Back to dashboard" → "Back to events" across event detail,
  billing, account, and new-event pages

Tests
- TestBrandingGetReturnsDefaults / TestBrandingPutPersists /
  TestBrandingPutRejectsBadInputs / TestUploadAndServeImage /
  TestUploadRejectsNonImage — all pass
- Domain tests for IsValidHexColor + IsAllowedFont
- Full integration suite green (176s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:04:09 +01:00

201 lines
6.3 KiB
Go

package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Env string
HTTPAddr string
DatabaseURL string
NATSURL string
RedisAddr string
FraudGRPCAddr string
FraudGRPCTimeout time.Duration
ShutdownTimeout time.Duration
TokenSecret string
TokenTTL time.Duration
// Auth
JWTSecret string
JWTIssuer string
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
EmailVerificationTTL time.Duration
PasswordResetTTL time.Duration
PublicBaseURL string
RefreshCookieDomain string
RefreshCookieSecure bool
// Notifications (Block D). Empty values leave the log-stub adapter in
// place so the service still boots without AWS / Twilio creds.
SESRegion string
SESFromEmail string
SESFromName string
SESConfigurationSet string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFromEmail string
SMTPFromName string
SMTPTLS string // "starttls" | "implicit" | "none" (default starttls)
ResendAPIKey string
ResendFromEmail string
ResendFromName string
TwilioAccountSID string
TwilioAuthToken string
TwilioFromNumber string
// Billing (Block F). Empty StripeSecretKey leaves billing disabled —
// all users get free-tier limits, /billing/* returns 503. Lets the
// service boot in dev without Stripe creds.
StripeSecretKey string
StripeWebhookSecret string
StripePricePro string // Stripe Price ID for Pro monthly
StripePriceBusiness string // Stripe Price ID for Business monthly
UnsubscribeSecret string // HMAC key for signing unsubscribe links
// Uploads (Tier 2 Block D). LocalFSStore writes to UploadsDir and
// serves via the API's /uploads/<file> route; in production this is
// a deployment concern (S3 bucket + CDN URL).
UploadsDir string
UploadsPublicURL string
}
func Load() (*Config, error) {
cfg := &Config{
Env: getenv("GG_ENV", "development"),
HTTPAddr: getenv("GG_HTTP_ADDR", ":8080"),
DatabaseURL: getenv("GG_DATABASE_URL", "postgres://guestguard:guestguard@localhost:5432/guestguard?sslmode=disable"),
NATSURL: getenv("GG_NATS_URL", "nats://localhost:4222"),
RedisAddr: getenv("GG_REDIS_ADDR", "localhost:6379"),
FraudGRPCAddr: getenv("GG_FRAUD_GRPC_ADDR", "fraud-engine:9091"),
FraudGRPCTimeout: getenvDuration("GG_FRAUD_GRPC_TIMEOUT", 250*time.Millisecond),
ShutdownTimeout: getenvDuration("GG_SHUTDOWN_TIMEOUT", 15*time.Second),
TokenSecret: os.Getenv("GG_TOKEN_SECRET"),
TokenTTL: getenvDuration("GG_TOKEN_TTL", 30*24*time.Hour),
JWTSecret: os.Getenv("GG_JWT_SECRET"),
JWTIssuer: getenv("GG_JWT_ISSUER", "guestguard"),
AccessTokenTTL: getenvDuration("GG_ACCESS_TOKEN_TTL", 15*time.Minute),
RefreshTokenTTL: getenvDuration("GG_REFRESH_TOKEN_TTL", 30*24*time.Hour),
EmailVerificationTTL: getenvDuration("GG_EMAIL_VERIFICATION_TTL", 24*time.Hour),
PasswordResetTTL: getenvDuration("GG_PASSWORD_RESET_TTL", 1*time.Hour),
PublicBaseURL: getenv("GG_PUBLIC_BASE_URL", "http://localhost:3000"),
RefreshCookieDomain: os.Getenv("GG_REFRESH_COOKIE_DOMAIN"),
RefreshCookieSecure: getenvBool("GG_REFRESH_COOKIE_SECURE", false),
SESRegion: getenv("GG_SES_REGION", "us-east-1"),
SESFromEmail: os.Getenv("GG_SES_FROM_EMAIL"),
SESFromName: getenv("GG_SES_FROM_NAME", "GuestGuard"),
SESConfigurationSet: os.Getenv("GG_SES_CONFIGURATION_SET"),
SMTPHost: os.Getenv("GG_SMTP_HOST"),
SMTPPort: getenvInt("GG_SMTP_PORT", 587),
SMTPUsername: os.Getenv("GG_SMTP_USERNAME"),
SMTPPassword: os.Getenv("GG_SMTP_PASSWORD"),
SMTPFromEmail: os.Getenv("GG_SMTP_FROM_EMAIL"),
SMTPFromName: getenv("GG_SMTP_FROM_NAME", "GuestGuard"),
SMTPTLS: getenv("GG_SMTP_TLS", "starttls"),
ResendAPIKey: os.Getenv("GG_RESEND_API_KEY"),
ResendFromEmail: os.Getenv("GG_RESEND_FROM_EMAIL"),
ResendFromName: getenv("GG_RESEND_FROM_NAME", "GuestGuard"),
TwilioAccountSID: os.Getenv("GG_TWILIO_ACCOUNT_SID"),
TwilioAuthToken: os.Getenv("GG_TWILIO_AUTH_TOKEN"),
TwilioFromNumber: os.Getenv("GG_TWILIO_FROM_NUMBER"),
StripeSecretKey: os.Getenv("GG_STRIPE_SECRET_KEY"),
StripeWebhookSecret: os.Getenv("GG_STRIPE_WEBHOOK_SECRET"),
StripePricePro: os.Getenv("GG_STRIPE_PRICE_PRO"),
StripePriceBusiness: os.Getenv("GG_STRIPE_PRICE_BUSINESS"),
UnsubscribeSecret: os.Getenv("GG_UNSUBSCRIBE_SECRET"),
UploadsDir: getenv("GG_UPLOADS_DIR", "/var/lib/guestguard/uploads"),
UploadsPublicURL: getenv("GG_UPLOADS_PUBLIC_URL", "http://localhost:8080/uploads"),
}
if cfg.Env == "production" && cfg.TokenSecret == "" {
return nil, fmt.Errorf("GG_TOKEN_SECRET is required in production")
}
if cfg.TokenSecret == "" {
cfg.TokenSecret = "dev-only-insecure-secret-change-me"
}
if cfg.Env == "production" && cfg.JWTSecret == "" {
return nil, fmt.Errorf("GG_JWT_SECRET is required in production")
}
if cfg.JWTSecret == "" {
cfg.JWTSecret = "dev-only-insecure-jwt-secret-change-me-32+bytes"
}
if len(cfg.JWTSecret) < 32 {
return nil, fmt.Errorf("GG_JWT_SECRET must be at least 32 bytes")
}
if cfg.UnsubscribeSecret == "" {
// Same dev fallback shape as the other secrets — production refuses
// to boot without it.
if cfg.Env == "production" {
return nil, fmt.Errorf("GG_UNSUBSCRIBE_SECRET is required in production")
}
cfg.UnsubscribeSecret = "dev-only-insecure-unsubscribe-secret-change-me"
}
return cfg, nil
}
func getenvInt(key string, fallback int) int {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func getenvBool(key string, fallback bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func getenvDuration(key string, fallback time.Duration) time.Duration {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
if d, err := time.ParseDuration(v); err == nil {
return d
}
if secs, err := strconv.Atoi(v); err == nil {
return time.Duration(secs) * time.Second
}
return fallback
}