// Package di wires concrete adapters into the application core. Each command // builds only what it needs (the frontend command touches no infrastructure). package di import ( "context" "log" "net/http" "time" "github.com/snip/backend/internal/adapter/identity" "github.com/snip/backend/internal/adapter/memory" "github.com/snip/backend/internal/adapter/postgres" "github.com/snip/backend/internal/adapter/rediscache" "github.com/snip/backend/internal/adapter/security" "github.com/snip/backend/internal/config" "github.com/snip/backend/internal/httpx/api" "github.com/snip/backend/internal/httpx/frontend" "github.com/snip/backend/internal/httpx/redirect" "github.com/snip/backend/internal/port" "github.com/snip/backend/internal/service" ) // Container holds config and constructs per-command applications. type Container struct { cfg config.Config } func New(cfg config.Config) *Container { return &Container{cfg: cfg} } // App is a runnable handler plus its resource closers. type App struct { Handler http.Handler closers []func() } func (a *App) Close() { for i := len(a.closers) - 1; i >= 0; i-- { a.closers[i]() } } type infra struct { links port.LinkRepository users port.UserRepository cache port.LinkCache closers []func() } // buildInfra picks the storage adapters based on STORE. func (c *Container) buildInfra(ctx context.Context) (*infra, error) { if c.cfg.Store == "memory" { return &infra{links: memory.NewLinkRepo(), users: memory.NewUserRepo(), cache: memory.NewCache()}, nil } pool, err := postgres.Connect(ctx, c.cfg.DatabaseURL) if err != nil { return nil, err } cache, err := rediscache.New(ctx, c.cfg.RedisAddr, c.cfg.RedisPassword, c.cfg.RedisDB, c.cfg.RedisKeyPrefix) if err != nil { pool.Close() return nil, err } return &infra{ links: postgres.NewLinkRepo(pool), users: postgres.NewUserRepo(pool), cache: cache, closers: []func(){pool.Close, func() { _ = cache.Close() }}, }, nil } // BuildFrontend constructs the static SPA server (no DB/Redis). func (c *Container) BuildFrontend() *App { return &App{Handler: frontend.New(c.cfg.FrontendDist).Handler()} } // BuildAPI constructs the /api/v1 server. OIDC provider discovery runs in the // background so the HTTP server (and its health endpoint) starts immediately. // Providers become available once discovery completes; until then the /auth/* // endpoints return an empty provider list and login buttons are hidden. func (c *Container) BuildAPI(ctx context.Context) (*App, error) { inf, err := c.buildInfra(ctx) if err != nil { return nil, err } hasher := security.NewBcryptHasher() sessions := security.NewHMACSessions(c.cfg.SessionSecret) linkSvc := service.NewLinkService(inf.links, inf.cache, hasher) authSvc := service.NewAuthService(nil, inf.users, sessions) // providers injected below h := api.New(linkSvc, authSvc, sessions, c.cfg).Handler() // Discover OIDC providers in the background; inject them once ready. go func() { providers := c.buildProviders(ctx) authSvc.SetProviders(providers) log.Printf("di: %d identity provider(s) ready", len(providers)) }() return &App{Handler: h, closers: inf.closers}, nil } // BuildRedirect constructs the redirect server and starts the click flusher. func (c *Container) BuildRedirect(ctx context.Context) (*App, error) { inf, err := c.buildInfra(ctx) if err != nil { return nil, err } hasher := security.NewBcryptHasher() recorder := service.NewAsyncClickRecorder(inf.links, c.cfg.ClickFlushInterval) recorder.Start(ctx) redirectSvc := service.NewRedirectService(inf.links, inf.cache, hasher, recorder) h := redirect.New(redirectSvc, c.cfg.ShortDomain, c.cfg.PublicURL).Handler() closers := append([]func(){recorder.Stop}, inf.closers...) return &App{Handler: h, closers: closers}, nil } // buildProviders configures the available login methods. Each is optional: if // its credentials are absent (or discovery fails) the button simply won't show. func (c *Container) buildProviders(ctx context.Context) []port.IdentityProvider { var providers []port.IdentityProvider callback := func(name string) string { return c.cfg.PublicURL + "/api/v1/auth/" + name + "/callback" } if c.cfg.GoogleClientID != "" && c.cfg.GoogleClientSecret != "" { if p := dialProvider(ctx, "google", identity.GoogleIssuer, c.cfg.GoogleClientID, c.cfg.GoogleClientSecret, callback("google")); p != nil { providers = append(providers, p) } } if c.cfg.OIDCIssuer != "" && c.cfg.OIDCClientID != "" { if p := dialProvider(ctx, "oidc", c.cfg.OIDCIssuer, c.cfg.OIDCClientID, c.cfg.OIDCClientSecret, callback("oidc")); p != nil { providers = append(providers, p) } } return providers } // dialProvider performs OIDC discovery with a short retry, so the api command // can start alongside a still-booting (mock) provider instead of silently // disabling login on a transient connection error. func dialProvider(ctx context.Context, name, issuer, clientID, secret, redirect string) port.IdentityProvider { var lastErr error for attempt := 1; attempt <= 15; attempt++ { p, err := identity.NewOIDCProvider(ctx, name, issuer, clientID, secret, redirect, nil) if err == nil { log.Printf("di: %s provider ready (issuer %s)", name, issuer) return p } lastErr = err select { case <-ctx.Done(): return nil case <-time.After(2 * time.Second): } } log.Printf("di: %s provider disabled after retries: %v", name, lastErr) return nil }