Files
warmbly/cmd/backend/main.go
T

720 lines
22 KiB
Go

package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/MicahParks/keyfunc/v3"
awsconf "github.com/aws/aws-sdk-go-v2/config"
"github.com/getsentry/sentry-go"
"github.com/meszmate/apple-go"
"github.com/meszmate/google-go"
"github.com/warmbly/warmbly/internal/api"
"github.com/warmbly/warmbly/internal/api/handler"
"github.com/warmbly/warmbly/internal/api/middleware"
"github.com/warmbly/warmbly/internal/app/admin"
"github.com/warmbly/warmbly/internal/app/advanced"
"github.com/warmbly/warmbly/internal/app/apikey"
"github.com/warmbly/warmbly/internal/app/audit"
"github.com/warmbly/warmbly/internal/app/auth"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cipher"
"github.com/warmbly/warmbly/internal/app/contact"
"github.com/warmbly/warmbly/internal/app/crm"
"github.com/warmbly/warmbly/internal/app/dangerzone"
"github.com/warmbly/warmbly/internal/app/email"
"github.com/warmbly/warmbly/internal/app/emailsend"
"github.com/warmbly/warmbly/internal/app/feature"
"github.com/warmbly/warmbly/internal/app/group"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/releases"
"github.com/warmbly/warmbly/internal/app/sequence"
"github.com/warmbly/warmbly/internal/app/socket"
"github.com/warmbly/warmbly/internal/app/stripe"
"github.com/warmbly/warmbly/internal/app/subscription"
"github.com/warmbly/warmbly/internal/app/template"
"github.com/warmbly/warmbly/internal/app/token"
"github.com/warmbly/warmbly/internal/app/trial"
"github.com/warmbly/warmbly/internal/app/tz"
"github.com/warmbly/warmbly/internal/app/unibox"
"github.com/warmbly/warmbly/internal/app/user"
warmupapp "github.com/warmbly/warmbly/internal/app/warmup"
"github.com/warmbly/warmbly/internal/app/worker"
"github.com/warmbly/warmbly/internal/app/worker_orchestrator"
"github.com/warmbly/warmbly/internal/config"
"github.com/warmbly/warmbly/internal/events"
"github.com/warmbly/warmbly/internal/infrastructure/cache"
"github.com/warmbly/warmbly/internal/infrastructure/db"
"github.com/warmbly/warmbly/internal/infrastructure/dynamo"
"github.com/warmbly/warmbly/internal/infrastructure/gtasks"
"github.com/warmbly/warmbly/internal/infrastructure/kafka"
"github.com/warmbly/warmbly/internal/infrastructure/kms"
"github.com/warmbly/warmbly/internal/infrastructure/pubsub"
"github.com/warmbly/warmbly/internal/infrastructure/storage"
"github.com/warmbly/warmbly/internal/jobs"
"github.com/warmbly/warmbly/internal/models"
"github.com/warmbly/warmbly/internal/notify"
"github.com/warmbly/warmbly/internal/observability"
"github.com/warmbly/warmbly/internal/pkg/captcha"
"github.com/warmbly/warmbly/internal/pkg/geo"
"github.com/warmbly/warmbly/internal/repository"
"github.com/warmbly/warmbly/internal/scheduler"
"github.com/warmbly/warmbly/internal/tasks"
)
func main() {
var addr string
var ginMode string
var websocketURI string
var allowedOrigins []string
var tzService tz.TzService
var serviceAccount string
var keySet keyfunc.Keyfunc
var tokenService token.TokenService
var authService auth.AuthService
var userService user.UserService
var emailService email.EmailService
var campaignService campaign.CampaignService
var sequenceService sequence.SequenceService
var contactService contact.ContactService
var socketService socket.SocketService
var uniboxService unibox.UniboxService
var cipherService cipher.CipherService
var tasksService tasks.TasksService
var advancedService advanced.Service
var folderService group.GroupService
var tagService group.GroupService
var categoryService group.GroupService
var crmService crm.CRMService
var apiKeyService apikey.APIKeyService
// New services for trial, feature gates, and worker assignment
var trialService trial.TrialService
var featureGateService feature.FeatureGateService
var workerAssignmentService worker.WorkerAssignmentService
var subscriptionService subscription.SubscriptionService
var stripeService stripe.StripeService
var organizationService organization.OrganizationService
// Email send & templates
var templateService template.TemplateService
var emailSendService emailsend.EmailSendService
// Admin
var adminService admin.AdminService
// Worker orchestrator (SSH-driven admin worker lifecycle)
var workerOrchestrator *worker_orchestrator.Orchestrator
var workerRepoForHandler repository.WorkerRepository
var credentialsRepository repository.CredentialsRepository
var releasesService *releases.Service
// Notifications
var emailNotificationService notify.EmailNotificationService
// Warmup
var warmupService warmupapp.Service
// Danger zone (delayed deletions)
var dangerZoneService dangerzone.Service
// Pub/Sub for realtime streaming
var streamingPublisher *pubsub.StreamingPublisher
// Surfaced into the handler for avatar uploads and other direct
// repository / object-storage needs. Declared up here so they
// survive the config block where they're initialized.
var s3ForHandler *storage.Client
var userRepoForHandler repository.UserRepository
var organizationRepoForHandler repository.OrganizationRepository
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
{
// Load config with env-first approach
cfg, err := config.NewConfig(ctx)
if err != nil {
log.Fatal(err)
}
if err := observability.InitSentry(ctx, cfg, "backend"); err != nil {
log.Fatal(err)
}
serviceAccount, err = cfg.LoadGoogleServiceAccount(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
keySet, err = keyfunc.NewDefaultCtx(ctx, []string{"https://www.googleapis.com/oauth2/v3/certs"})
if err != nil {
if cfg.Env == "dev" {
log.Printf("Warning: Failed to fetch Google OIDC keys: %v", err)
} else {
sentry.CaptureException(err)
log.Fatal(err)
}
}
apiCfg, err := cfg.LoadApiConfig(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
// AWS config for services that need it (KMS, S3, DynamoDB)
awscfg, err := awsconf.LoadDefaultConfig(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
var masterKey string = "alias/master-key"
if cfg.Env != "prod" {
masterKey += "-dev"
}
kms, err := kms.New(ctx, awscfg, masterKey)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
geoPath, err := cfg.LoadGeoDBPath(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
var geoloc *geo.Client
geoloc, err = geo.New(geoPath)
if err != nil {
if cfg.Env == "dev" {
log.Printf("Warning: GeoIP database not found at %s, geo lookups disabled", geoPath)
} else {
sentry.CaptureException(err)
log.Fatal(err)
}
}
s3, err := storage.NewClient(ctx, awscfg, "main")
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
s3ForHandler = s3
primaryDBEndpoint, err := cfg.LoadPrimaryDBEndpoint(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
primaryDB, err := db.New(ctx, primaryDBEndpoint)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
// Run database migrations
log.Println("Running database migrations...")
if err := db.RunMigrations(primaryDBEndpoint); err != nil {
sentry.CaptureException(err)
log.Fatal("Failed to run migrations: ", err)
}
log.Println("Database migrations completed")
dynamoDB, err := dynamo.NewClient(ctx, awscfg)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
primaryRedis, err := cfg.LoadPrimaryRedisEndpoint(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
cache, err := cache.New(primaryRedis)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
// Google Pub/Sub for realtime streaming (optional)
gcpProjectID := os.Getenv("GCP_PROJECT_ID")
if gcpProjectID != "" {
pubsubClient, err := pubsub.NewClient(ctx, gcpProjectID)
if err != nil {
sentry.CaptureException(err)
log.Printf("Warning: Failed to initialize Pub/Sub client: %v", err)
} else {
streamingPublisher = pubsub.NewStreamingPublisher(pubsubClient)
}
}
emailCfg, err := cfg.LoadEmailConfig(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
smtpCfg := cfg.LoadSMTPConfig(ctx)
if smtpCfg != nil {
emailNotificationService = notify.NewSMTPEmailNotificationService(
emailCfg.EmailName,
emailCfg.EmailAddress,
smtpCfg.Host,
smtpCfg.Port,
)
} else {
emailNotificationService, err = notify.NewEmailNotficiationService(
ctx,
emailCfg.EmailName,
emailCfg.EmailAddress,
)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
}
authCfg, err := cfg.LoadAuthConfig(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
googleAuth := google.NewAuth(
authCfg.GoogleClientID,
authCfg.GoogleClientSecret,
authCfg.GoogleRedirectURI,
nil,
)
var appleAuthClient apple.AppleAuth
appleAuthInstance, appleErr := apple.NewB64(
authCfg.AppleAppID,
authCfg.AppleTeamID,
authCfg.AppleKeyID,
authCfg.AppleKeySecret,
)
if appleErr != nil {
if cfg.Env == "dev" {
log.Printf("Warning: Apple auth initialization failed (expected in dev): %v", appleErr)
} else {
sentry.CaptureException(appleErr)
log.Fatal(appleErr)
}
} else {
appleAuthClient = appleAuthInstance
}
kafkaBootstrapServers, err := cfg.LoadKafkaBootstrapServers(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
kafkaSaslConfig, err := cfg.LoadKafkaConfigSasl(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
schemaEndpoint, schemaKey, schemaSecret, err := cfg.LoadSchemaRegistryConfig(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
avrov2Client, err := kafka.NewAvrov2Client(schemaEndpoint, schemaKey, schemaSecret)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
kafkaProducerConfig := kafka.NewProducer(kafkaBootstrapServers)
if kafkaSaslConfig != nil {
kafkaProducerConfig.WithSASL(kafkaSaslConfig)
}
kafkaProducer, err := kafkaProducerConfig.Connect()
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
kafkaProducer.WithAvrov2(avrov2Client)
turnstileBypassToken := ""
if cfg.Env == "dev" {
turnstileBypassToken = authCfg.TurnstileBypass
if turnstileBypassToken == "" {
turnstileBypassToken = "warmbly-local-turnstile-bypass"
}
}
captcha := captcha.NewTurnstileFromConfig(captcha.TurnstileConfig{
Secret: authCfg.TurnstileSecret,
BypassToken: turnstileBypassToken,
})
userRepostory := repository.NewUserRepostory(primaryDB, kms)
userRepoForHandler = userRepostory
authRepostory := repository.NewAuthRepostory(primaryDB)
tokenRepostory := repository.NewTokenRepostory(primaryDB)
emailRepostory := repository.NewEmailRepostory(primaryDB)
campaignRepostory := repository.NewCampaignRepostory(primaryDB)
sequenceRepostory := repository.NewSequenceRepostory(primaryDB)
contactRepostory := repository.NewContactRepostory(primaryDB)
uniboxRepository := repository.NewUniboxRepository(primaryDB)
userEncryptedKeysRepository := repository.NewUserEncryptedKeysRepository(kms, dynamoDB)
folderRepostory := repository.NewGroupRepostory(primaryDB, models.Folders)
tagRepostory := repository.NewGroupRepostory(primaryDB, models.Tags)
categoryRepostory := repository.NewGroupRepostory(primaryDB, models.Categories)
// New repositories for subscription & worker management
subscriptionRepository := repository.NewSubscriptionRepository(primaryDB.Pool)
planRepository := repository.NewPlanRepository(primaryDB.Pool)
workerRepository := repository.NewWorkerRepository(primaryDB.Pool)
organizationRepository := repository.NewOrganizationRepository(primaryDB.Pool)
organizationRepoForHandler = organizationRepository
taskRepository := repository.NewTaskRepository(primaryDB.Pool)
apiKeyRepository := repository.NewAPIKeyRepository(primaryDB)
crmRepository := repository.NewCRMRepository(primaryDB.Pool)
advancedRepository := repository.NewAdvancedOutreachRepository(primaryDB.Pool)
templateRepository := repository.NewTemplateRepository(primaryDB.Pool)
warmupRepository := repository.NewWarmupRepository(primaryDB.Pool)
campaignProgressRepository := repository.NewCampaignProgressRepository(primaryDB.Pool)
campaignLogRepository := repository.NewCampaignLogRepository(primaryDB)
warmupService = warmupapp.NewService(warmupRepository)
tzService = tz.NewService()
// Initialize new services for trial, feature gates, and worker assignment
trialService = trial.NewService(subscriptionRepository, userRepostory)
featureGateService = feature.NewService(subscriptionRepository, planRepository)
workerAssignmentService = worker.NewAssignmentService(workerRepository, subscriptionRepository, planRepository)
subscriptionService = subscription.NewService(subscriptionRepository, planRepository)
organizationService = organization.NewService(organizationRepository, subscriptionRepository, userRepostory)
// Load Stripe config and initialize service
stripeCfg, err := cfg.LoadStripeConfig(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
stripeService = stripe.NewService(stripeCfg, subscriptionRepository, planRepository, workerAssignmentService)
tokenService = token.NewService(primaryDB, tokenRepostory, cache, geoloc, authCfg.AuthSecret)
userService = user.NewService(userRepostory, cache)
authService = auth.NewService(
authRepostory,
cache,
captcha,
tokenService,
emailNotificationService,
&models.ExternalAuth{
GoogleAuth: googleAuth,
AppleAuth: appleAuthClient,
},
trialService,
organizationService,
userRepostory,
userService,
)
cipherService = cipher.NewService(kms, cache, userEncryptedKeysRepository)
// Worker orchestrator. The env config below is the FALLBACK that gets
// written into /etc/warmbly/worker.env when a worker has no profile
// assigned. Production workers should reference a worker_profile row;
// dev/sim can rely on the fallback so docker-compose still works.
workerRepoForHandler = workerRepository
credentialsRepository = repository.NewCredentialsRepository(primaryDB.Pool)
workerOrchestrator = worker_orchestrator.New(
workerRepository,
credentialsRepository,
cipherService,
worker_orchestrator.WorkerEnvConfig{
AppEnv: os.Getenv("APP_ENV"),
WorkerImage: getenvDefault("WORKER_IMAGE", "ghcr.io/warmbly/worker:latest"),
KafkaBootstrap: os.Getenv("KAFKA_BOOTSTRAP_SERVERS"),
KafkaSASLUsername: os.Getenv("KAFKA_SASL_USERNAME"),
KafkaSASLPassword: os.Getenv("KAFKA_SASL_PASSWORD"),
SchemaRegistryURL: os.Getenv("SCHEMA_REGISTRY_URL"),
SchemaRegistryKey: os.Getenv("SCHEMA_REGISTRY_KEY"),
SchemaRegistrySecret: os.Getenv("SCHEMA_REGISTRY_SECRET"),
RedisURL: os.Getenv("REDIS"),
AWSRegion: os.Getenv("AWS_REGION"),
AWSAccessKeyID: os.Getenv("WORKER_AWS_ACCESS_KEY_ID"),
AWSSecretAccessKey: os.Getenv("WORKER_AWS_SECRET_ACCESS_KEY"),
},
getenvDefault("WORKER_INSTALLER_PATH", "/app/scripts/install-worker.sh"),
)
// Releases service. Env-configurable so self-hosters can point at their
// own repo/registry, or disable the feature entirely.
releasesService = releases.New(
releases.Config{
Enabled: getenvDefault("RELEASES_ENABLED", "true") == "true",
GithubRepo: getenvDefault("RELEASES_GITHUB_REPO", "warmbly/warmbly"),
WorkerImageRepo: getenvDefault("RELEASES_WORKER_IMAGE_REPO", "ghcr.io/warmbly/warmbly/worker"),
WebhookSecret: os.Getenv("RELEASES_WEBHOOK_SECRET"),
GithubToken: os.Getenv("RELEASES_GITHUB_TOKEN"),
},
credentialsRepository,
workerRepository,
workerOrchestrator,
)
releasesService.RunBootCheck(ctx)
eventsPublisher := events.NewPublisher(kafkaProducer, s3, avrov2Client, cipherService)
oauth2Cfg := config.LoadOauth2(apiCfg.Hostname)
emailService = email.NewServiceWithKafka(
emailRepostory,
cipherService,
featureGateService,
warmupService,
eventsPublisher,
kafkaProducer,
cache,
&oauth2Cfg.InboxAuthorization,
workerAssignmentService,
)
campaignService = campaign.NewService(campaignRepostory, taskRepository, emailRepostory, campaignLogRepository, featureGateService, streamingPublisher)
sequenceService = sequence.NewService(sequenceRepostory)
contactService = contact.NewService(contactRepostory, subscriptionRepository, planRepository)
apiKeyService = apikey.NewService(cache, apiKeyRepository)
crmService = crm.NewService(crmRepository)
socketService = socket.NewService(cache, tokenService)
uniboxService = unibox.NewService(cache, s3, uniboxRepository)
// Cloud Tasks client
cloudTasksCfg, err := cfg.LoadCloudTasksConfig(ctx)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
tasksClient, err := gtasks.NewClient(ctx, cloudTasksCfg.QueueName, cloudTasksCfg.WebhookURL, serviceAccount, cloudTasksCfg.EmulatorHost)
if err != nil {
sentry.CaptureException(err)
log.Fatal(err)
}
// Template & email send services
templateService = template.NewService(templateRepository)
schedulerService := scheduler.NewSchedulerService(taskRepository, warmupRepository, campaignProgressRepository, emailRepostory, campaignRepostory)
emailSendService = emailsend.NewService(taskRepository, emailRepostory, schedulerService, tasksClient, featureGateService)
advancedService = advanced.NewService(
advancedRepository,
campaignRepostory,
emailRepostory,
taskRepository,
contactRepostory,
campaignProgressRepository,
crmRepository,
tasksClient,
warmupService,
)
emailSender := tasks.NewEmailSender(emailRepostory, eventsPublisher)
tasksService = tasks.NewService(
tasksClient,
kafkaProducer,
nil, // AI generation client is optional for task execution
streamingPublisher,
eventsPublisher,
schedulerService,
cipherService,
emailSender,
featureGateService,
warmupService,
taskRepository,
warmupRepository,
campaignProgressRepository,
emailRepostory,
campaignRepostory,
contactRepostory,
campaignLogRepository,
advancedService,
)
// Admin service
adminRepository := repository.NewAdminRepository(primaryDB.Pool)
adminService = admin.NewService(adminRepository)
folderService = group.NewService(folderRepostory)
tagService = group.NewService(tagRepostory)
categoryService = group.NewService(categoryRepostory)
// Start trial expiration job in background
trialExpirationJob := jobs.NewTrialExpirationJobWithDB(subscriptionRepository, primaryDB.Pool, emailNotificationService)
trialScheduler := jobs.NewTrialExpirationScheduler(trialExpirationJob, 1*time.Hour)
go trialScheduler.Start(ctx)
// Danger zone: schedule + execute delayed deletions (orgs, accounts).
dangerZoneRepository := repository.NewDangerZoneRepository(primaryDB.Pool)
dangerZoneService = dangerzone.NewService(
dangerZoneRepository,
organizationRepository,
userRepostory,
emailNotificationService,
os.Getenv("FRONTEND_BASE_URL"),
)
dangerZoneJob := jobs.NewDangerZoneJob(dangerZoneService)
dangerZoneScheduler := jobs.NewDangerZoneScheduler(dangerZoneJob, 1*time.Hour)
go dangerZoneScheduler.Start(ctx)
addr = apiCfg.Hostname
ginMode = apiCfg.GinMode
websocketURI = apiCfg.WebsocketURI
allowedOrigins = apiCfg.AllowedOrigins
}
h := &handler.Handler{
AuthService: authService,
TokenService: tokenService,
UserService: userService,
EmailService: emailService,
CampaignService: campaignService,
ContactService: contactService,
SequenceService: sequenceService,
UniboxService: uniboxService,
FolderService: folderService,
TagService: tagService,
CategoryService: categoryService,
TzService: tzService,
SocketService: socketService,
TasksService: tasksService,
// API Keys
APIKeyService: apiKeyService,
// Subscription & billing
SubscriptionService: subscriptionService,
StripeService: stripeService,
// Trial & feature gates
TrialService: trialService,
FeatureGateService: featureGateService,
WorkerAssignmentService: workerAssignmentService,
// Organization & IAM
OrganizationService: organizationService,
// CRM
CRMService: crmService,
// Email send & templates
TemplateService: templateService,
EmailSendService: emailSendService,
// Admin
AdminService: adminService,
// SSH-managed worker lifecycle
WorkerOrchestrator: workerOrchestrator,
WorkerRepo: workerRepoForHandler,
CredentialsRepo: credentialsRepository,
ReleasesService: releasesService,
// Notifications
EmailNotificationService: emailNotificationService,
// Advanced outreach controls
AdvancedService: advancedService,
// Warmup health
WarmupService: warmupService,
WebsocketURI: websocketURI,
// Object storage + direct repository handles for handlers
// without a dedicated service layer (avatars, etc.).
Storage: s3ForHandler,
UserRepo: userRepoForHandler,
OrgRepo: organizationRepoForHandler,
// Danger zone
DangerZoneService: dangerZoneService,
// Audit logs aren't persisted yet; install a no-op so the
// many h.AuditService.LogAction sites don't panic on a nil
// interface. Swap for audit.NewService(repo) when wiring the
// real repository.
AuditService: audit.NewNoOpService(),
}
m := &middleware.Handler{
TokenService: tokenService,
APIKeyService: apiKeyService,
OrganizationService: organizationService,
}
oidcH := &middleware.OidcHandler{
ServiceAccount: serviceAccount,
KeySet: keySet,
AppEnv: os.Getenv("APP_ENV"),
}
sentry.CaptureMessage("Starting the backend on " + addr)
router := api.Run(h, m, oidcH, addr, ginMode, allowedOrigins)
srv := &http.Server{
Addr: addr,
Handler: router,
}
// Start server in a goroutine
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
log.Println("Backend started on", addr)
// Wait for interrupt signal for graceful shutdown
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("Shutting down backend...")
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
log.Println("Backend stopped")
}
func getenvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}