e5b187c575
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>
201 lines
6.3 KiB
Go
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
|
|
}
|