mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
161 lines
5.3 KiB
Go
161 lines
5.3 KiB
Go
// 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)
|
|
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
|
|
}
|