// Command app is the Bounty Board main service. package main import ( "context" "encoding/json" "fmt" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "time" "bountyboard/internal/atomize" "bountyboard/internal/auth" "bountyboard/internal/config" "bountyboard/internal/crypto" "bountyboard/internal/domain" "bountyboard/internal/files" httpx "bountyboard/internal/http" "bountyboard/internal/jobs" "bountyboard/internal/metrics" "bountyboard/internal/store" syncpkg "bountyboard/internal/sync" "bountyboard/internal/ws" ) func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "fatal:", err) os.Exit(1) } } func run() error { if err := config.LoadDotEnv(".env"); err != nil { return fmt.Errorf("load .env: %w", err) } cfg, err := config.Load() if err != nil { return err } log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(log) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() st, err := store.Connect(ctx, cfg, log) if err != nil { return err } defer func() { closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := st.Close(closeCtx); err != nil { log.Error("close mongo", "err", err) } }() if err := auth.EnsureBootstrapAdmin(ctx, st, cfg, log); err != nil { return err } reg := metrics.NewRegistry() fileStore := files.NewStore(st.DB, cfg.MaxUploadMB) srv := httpx.New(cfg, log, reg, st, fileStore) srv.AddReadinessCheck(httpx.ReadinessCheck{ Name: "mongo", Required: true, Probe: st.Ping, }) // WebSocket hub: one multiplexed live channel per session (ยง2.2). hub := ws.NewHub(log, func(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true // non-browser client (no CSRF surface on a read feed) } return strings.HasPrefix(origin, cfg.AppBaseURL) || strings.HasPrefix(origin, "http://"+r.Host) || strings.HasPrefix(origin, "https://"+r.Host) }) srv.SetWSHandler(hub.Handle) srv.SetPublishFn(hub.Broadcast) // Atomizer client honoring the admin base-URL override per call. atomClient := atomize.New(func() string { sctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if settings, err := st.GetSettings(sctx); err == nil && settings.AtomizerBaseURLOverride != "" { return settings.AtomizerBaseURLOverride } return cfg.AtomizerBaseURL }, cfg.AtomizerToken, cfg.AtomizerTimeout) srv.SetAtomizerInfo(atomClient) srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy}) // Background job queue + atomization handlers. runner := jobs.NewRunner(st, log, reg, 4) atomSvc := atomize.NewService(st, atomClient, log, cfg.AppBaseURL, []byte(cfg.SessionSecret), srv.Publish) runner.Register(atomize.JobKindSubdivide, atomSvc.HandleSubdivide) runner.Register(atomize.JobKindExtend, atomSvc.HandleExtend) srv.SetJobsStatusProvider(runner) srv.SetEnqueue(runner.Enqueue) go runner.Run(ctx) syncMgr := syncpkg.NewManager(st, fileStore, log, reg, func(c *domain.Customer) (syncpkg.Credentials, error) { if c.Ticketing.CredentialsEnc == "" { return syncpkg.Credentials{}, nil } plain, err := crypto.Open(cfg.CredentialsEncKey, c.Ticketing.CredentialsEnc) if err != nil { return nil, err } var creds syncpkg.Credentials if err := json.Unmarshal(plain, &creds); err != nil { return nil, err } return creds, nil }, srv.Publish) srv.SetSyncStatusProvider(syncMgr) srv.SetSyncTrigger(syncMgr.SyncNow) go syncMgr.Run(ctx) return srv.Run(ctx) }