mirror of
https://github.com/btouchard/ackify-ce.git
synced 2026-02-08 23:08:58 -06:00
refactor(server): encapsulate service initialization in ServerBuilder
Move all service creation (I18n, Email, MagicLink, Session, Config) from main.go into ServerBuilder.Build(), making main.go minimal and self-contained. - ServerBuilder now only requires WithDB() and WithTenantProvider() - AuthProvider and Authorizer have sensible CE defaults - Rename DynamicAuthProvider → auth.Provider for simplicity - Remove unused With* methods for internal services
This commit is contained in:
@@ -8,20 +8,14 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/internal/application/services"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/auth"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/database"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/email"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/i18n"
|
||||
"github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/config"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/logger"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/web"
|
||||
webauth "github.com/btouchard/ackify-ce/backend/pkg/web/auth"
|
||||
)
|
||||
|
||||
// Build-time variables set via ldflags
|
||||
@@ -59,78 +53,12 @@ func main() {
|
||||
log.Fatalf("failed to initialize tenant provider: %v", err)
|
||||
}
|
||||
|
||||
// Create repositories needed for auth
|
||||
oauthSessionRepo := database.NewOAuthSessionRepository(db, tenantProvider)
|
||||
configRepo := database.NewConfigRepository(db, tenantProvider)
|
||||
magicLinkRepo := database.NewMagicLinkRepository(db)
|
||||
|
||||
// Create ConfigService (needed for dynamic auth config)
|
||||
encryptionKey := cfg.OAuth.CookieSecret
|
||||
configService := services.NewConfigService(configRepo, cfg, encryptionKey)
|
||||
|
||||
// Initialize config from DB or ENV
|
||||
err = tenant.WithTenantContextFromProvider(ctx, db, tenantProvider, func(txCtx context.Context) error {
|
||||
return configService.Initialize(txCtx)
|
||||
})
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to initialize config service, using ENV config", "error", err)
|
||||
}
|
||||
|
||||
// Create i18n service
|
||||
i18nService, err := i18n.NewI18n(getLocalesDir())
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize i18n: %v", err)
|
||||
}
|
||||
|
||||
// Create email renderer and sender if SMTP is configured
|
||||
var emailSender email.Sender
|
||||
var emailRenderer *email.Renderer
|
||||
if cfg.Mail.Host != "" {
|
||||
emailRenderer = email.NewRenderer(getTemplatesDir(), cfg.App.BaseURL, cfg.App.Organisation,
|
||||
cfg.Mail.FromName, cfg.Mail.From, cfg.Mail.DefaultLocale, i18nService)
|
||||
emailSender = email.NewSMTPSender(cfg.Mail, emailRenderer)
|
||||
}
|
||||
|
||||
// Create MagicLinkService
|
||||
magicLinkService := services.NewMagicLinkService(services.MagicLinkServiceConfig{
|
||||
Repository: magicLinkRepo,
|
||||
EmailSender: emailSender,
|
||||
I18n: i18nService,
|
||||
BaseURL: cfg.App.BaseURL,
|
||||
AppName: cfg.App.Organisation,
|
||||
RateLimitPerEmail: cfg.Auth.MagicLinkRateLimitEmail,
|
||||
RateLimitPerIP: cfg.Auth.MagicLinkRateLimitIP,
|
||||
})
|
||||
|
||||
// Create a SessionService (always needed for session management)
|
||||
sessionService := auth.NewSessionService(auth.SessionServiceConfig{
|
||||
CookieSecret: cfg.OAuth.CookieSecret,
|
||||
SecureCookies: cfg.App.SecureCookies,
|
||||
SessionRepo: oauthSessionRepo,
|
||||
})
|
||||
|
||||
// Create DynamicAuthProvider (unified auth for OIDC + MagicLink)
|
||||
authProvider := webauth.NewDynamicAuthProvider(webauth.DynamicAuthProviderConfig{
|
||||
ConfigProvider: configService,
|
||||
SessionService: sessionService,
|
||||
MagicLinkService: magicLinkService,
|
||||
BaseURL: cfg.App.BaseURL,
|
||||
})
|
||||
|
||||
// Create authorizer
|
||||
authorizer := webauth.NewSimpleAuthorizer(cfg.App.AdminEmails, cfg.App.OnlyAdminCanCreate)
|
||||
|
||||
// === Build Server ===
|
||||
// All services (I18n, Email, MagicLink, Config, Session) and
|
||||
// default providers (DynamicAuthProvider, SimpleAuthorizer) are created internally.
|
||||
server, err := web.NewServerBuilder(cfg, frontend, Version).
|
||||
WithDB(db).
|
||||
WithTenantProvider(tenantProvider).
|
||||
WithAuthProvider(authProvider).
|
||||
WithAuthorizer(authorizer).
|
||||
WithConfigService(configService).
|
||||
WithI18nService(i18nService).
|
||||
WithEmailSender(emailSender).
|
||||
WithEmailRenderer(emailRenderer).
|
||||
WithMagicLinkService(magicLinkService).
|
||||
Build(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create server: %v", err)
|
||||
@@ -158,49 +86,3 @@ func main() {
|
||||
|
||||
log.Println("Community Edition server exited")
|
||||
}
|
||||
|
||||
func getTemplatesDir() string {
|
||||
if envDir := os.Getenv("ACKIFY_TEMPLATES_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
|
||||
if execPath, err := os.Executable(); err == nil {
|
||||
execDir := filepath.Dir(execPath)
|
||||
defaultDir := filepath.Join(execDir, "templates")
|
||||
if _, err := os.Stat(defaultDir); err == nil {
|
||||
return defaultDir
|
||||
}
|
||||
}
|
||||
|
||||
possiblePaths := []string{"templates", "./templates"}
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return "templates"
|
||||
}
|
||||
|
||||
func getLocalesDir() string {
|
||||
if envDir := os.Getenv("ACKIFY_LOCALES_DIR"); envDir != "" {
|
||||
return envDir
|
||||
}
|
||||
|
||||
if execPath, err := os.Executable(); err == nil {
|
||||
execDir := filepath.Dir(execPath)
|
||||
defaultDir := filepath.Join(execDir, "locales")
|
||||
if _, err := os.Stat(defaultDir); err == nil {
|
||||
return defaultDir
|
||||
}
|
||||
}
|
||||
|
||||
possiblePaths := []string{"locales", "./locales"}
|
||||
for _, path := range possiblePaths {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return "locales"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ var (
|
||||
ErrInvalidCategory = errors.New("invalid configuration category")
|
||||
)
|
||||
|
||||
// configRepository defines the interface for config storage
|
||||
type configRepository interface {
|
||||
GetByCategory(ctx context.Context, category models.ConfigCategory) (*models.TenantConfig, error)
|
||||
GetAll(ctx context.Context) ([]*models.TenantConfig, error)
|
||||
|
||||
@@ -22,34 +22,32 @@ import (
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/types"
|
||||
)
|
||||
|
||||
// ConfigProvider provides dynamic configuration for auth.
|
||||
type ConfigProvider interface {
|
||||
type configProvider interface {
|
||||
GetConfig() *models.MutableConfig
|
||||
}
|
||||
|
||||
// MagicLinkService defines magic link operations.
|
||||
type MagicLinkService interface {
|
||||
type magicLinkService interface {
|
||||
RequestMagicLink(ctx context.Context, email, redirectTo, ip, userAgent, locale string) error
|
||||
VerifyMagicLink(ctx context.Context, token, ip, userAgent string) (*models.MagicLinkToken, error)
|
||||
VerifyReminderAuthToken(ctx context.Context, token, ip, userAgent string) (*models.MagicLinkToken, error)
|
||||
CreateReminderAuthToken(ctx context.Context, email, docID string) (string, error)
|
||||
}
|
||||
|
||||
// DynamicAuthProviderConfig holds configuration for creating a DynamicAuthProvider.
|
||||
type DynamicAuthProviderConfig struct {
|
||||
ConfigProvider ConfigProvider
|
||||
// ProviderConfig holds a configuration for creating a Provider.
|
||||
type ProviderConfig struct {
|
||||
ConfigProvider configProvider
|
||||
SessionService *infraAuth.SessionService
|
||||
MagicLinkService MagicLinkService
|
||||
MagicLinkService magicLinkService
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// DynamicAuthProvider implements providers.AuthProvider with dynamic config.
|
||||
// It reads OIDC/MagicLink configuration from ConfigProvider on each call,
|
||||
// Provider implements providers.AuthProvider with dynamic config.
|
||||
// It reads OIDC/MagicLink configuration from configProvider on each call,
|
||||
// supporting hot-reload of authentication settings.
|
||||
type DynamicAuthProvider struct {
|
||||
configProvider ConfigProvider
|
||||
type Provider struct {
|
||||
configProvider configProvider
|
||||
sessionService *infraAuth.SessionService
|
||||
magicLinkService MagicLinkService
|
||||
magicLinkService magicLinkService
|
||||
baseURL string
|
||||
|
||||
// Cache for oauth2.Config to avoid recreating on every request
|
||||
@@ -59,9 +57,9 @@ type DynamicAuthProvider struct {
|
||||
cachedOIDCCfg models.OIDCConfig
|
||||
}
|
||||
|
||||
// NewDynamicAuthProvider creates a new dynamic auth provider.
|
||||
func NewDynamicAuthProvider(cfg DynamicAuthProviderConfig) *DynamicAuthProvider {
|
||||
return &DynamicAuthProvider{
|
||||
// NewAuthProvider creates a new dynamic auth provider.
|
||||
func NewAuthProvider(cfg ProviderConfig) *Provider {
|
||||
return &Provider{
|
||||
configProvider: cfg.ConfigProvider,
|
||||
sessionService: cfg.SessionService,
|
||||
magicLinkService: cfg.MagicLinkService,
|
||||
@@ -71,30 +69,30 @@ func NewDynamicAuthProvider(cfg DynamicAuthProviderConfig) *DynamicAuthProvider
|
||||
|
||||
// === Session Management ===
|
||||
|
||||
func (p *DynamicAuthProvider) GetCurrentUser(r *http.Request) (*types.User, error) {
|
||||
func (p *Provider) GetCurrentUser(r *http.Request) (*types.User, error) {
|
||||
return p.sessionService.GetUser(r)
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) SetCurrentUser(w http.ResponseWriter, r *http.Request, user *types.User) error {
|
||||
func (p *Provider) SetCurrentUser(w http.ResponseWriter, r *http.Request, user *types.User) error {
|
||||
return p.sessionService.SetUser(w, r, user)
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
func (p *Provider) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
p.sessionService.Logout(w, r)
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) IsConfigured() bool {
|
||||
func (p *Provider) IsConfigured() bool {
|
||||
return p.IsOIDCEnabled() || p.IsMagicLinkEnabled()
|
||||
}
|
||||
|
||||
// === OIDC Authentication ===
|
||||
|
||||
func (p *DynamicAuthProvider) IsOIDCEnabled() bool {
|
||||
func (p *Provider) IsOIDCEnabled() bool {
|
||||
cfg := p.configProvider.GetConfig()
|
||||
return cfg.OIDC.Enabled && cfg.OIDC.ClientID != "" && cfg.OIDC.ClientSecret != ""
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) StartOIDC(w http.ResponseWriter, r *http.Request, nextURL string) string {
|
||||
func (p *Provider) StartOIDC(w http.ResponseWriter, r *http.Request, nextURL string) string {
|
||||
if !p.IsOIDCEnabled() {
|
||||
logger.Logger.Error("StartOIDC called but OIDC is not enabled")
|
||||
return ""
|
||||
@@ -144,7 +142,7 @@ func (p *DynamicAuthProvider) StartOIDC(w http.ResponseWriter, r *http.Request,
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"))
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) startOIDCWithoutPKCE(w http.ResponseWriter, r *http.Request, nextURL string, oauthConfig *oauth2.Config) string {
|
||||
func (p *Provider) startOIDCWithoutPKCE(w http.ResponseWriter, r *http.Request, nextURL string, oauthConfig *oauth2.Config) string {
|
||||
randPart := securecookie.GenerateRandomKey(20)
|
||||
token := base64.RawURLEncoding.EncodeToString(randPart)
|
||||
state := token + ":" + base64.RawURLEncoding.EncodeToString([]byte(nextURL))
|
||||
@@ -165,7 +163,7 @@ func (p *DynamicAuthProvider) startOIDCWithoutPKCE(w http.ResponseWriter, r *htt
|
||||
return oauthConfig.AuthCodeURL(state, oauth2.SetAuthURLParam("prompt", promptParam))
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) VerifyOIDCState(w http.ResponseWriter, r *http.Request, stateToken string) bool {
|
||||
func (p *Provider) VerifyOIDCState(w http.ResponseWriter, r *http.Request, stateToken string) bool {
|
||||
session, _ := p.sessionService.GetSession(r)
|
||||
stored, _ := session.Values["oauth_state"].(string)
|
||||
|
||||
@@ -182,7 +180,7 @@ func (p *DynamicAuthProvider) VerifyOIDCState(w http.ResponseWriter, r *http.Req
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) HandleOIDCCallback(ctx context.Context, w http.ResponseWriter, r *http.Request, code, state string) (*types.User, string, error) {
|
||||
func (p *Provider) HandleOIDCCallback(ctx context.Context, w http.ResponseWriter, r *http.Request, code, state string) (*types.User, string, error) {
|
||||
if !p.IsOIDCEnabled() {
|
||||
return nil, "/", fmt.Errorf("OIDC is not enabled")
|
||||
}
|
||||
@@ -252,7 +250,7 @@ func (p *DynamicAuthProvider) HandleOIDCCallback(ctx context.Context, w http.Res
|
||||
return user, nextURL, nil
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) GetOIDCLogoutURL() string {
|
||||
func (p *Provider) GetOIDCLogoutURL() string {
|
||||
cfg := p.configProvider.GetConfig()
|
||||
if cfg.OIDC.LogoutURL == "" {
|
||||
return ""
|
||||
@@ -260,7 +258,7 @@ func (p *DynamicAuthProvider) GetOIDCLogoutURL() string {
|
||||
return cfg.OIDC.LogoutURL + "?continue=" + p.baseURL
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) IsAllowedDomain(email string) bool {
|
||||
func (p *Provider) IsAllowedDomain(email string) bool {
|
||||
cfg := p.configProvider.GetConfig()
|
||||
if cfg.OIDC.AllowedDomain == "" {
|
||||
return true
|
||||
@@ -276,12 +274,12 @@ func (p *DynamicAuthProvider) IsAllowedDomain(email string) bool {
|
||||
|
||||
// === MagicLink Authentication ===
|
||||
|
||||
func (p *DynamicAuthProvider) IsMagicLinkEnabled() bool {
|
||||
func (p *Provider) IsMagicLinkEnabled() bool {
|
||||
cfg := p.configProvider.GetConfig()
|
||||
return cfg.MagicLink.Enabled && cfg.SMTP.Host != ""
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) RequestMagicLink(ctx context.Context, email, redirectTo, ip, userAgent, locale string) error {
|
||||
func (p *Provider) RequestMagicLink(ctx context.Context, email, redirectTo, ip, userAgent, locale string) error {
|
||||
if !p.IsMagicLinkEnabled() {
|
||||
return fmt.Errorf("MagicLink is not enabled")
|
||||
}
|
||||
@@ -291,7 +289,7 @@ func (p *DynamicAuthProvider) RequestMagicLink(ctx context.Context, email, redir
|
||||
return p.magicLinkService.RequestMagicLink(ctx, email, redirectTo, ip, userAgent, locale)
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) VerifyMagicLink(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) {
|
||||
func (p *Provider) VerifyMagicLink(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) {
|
||||
if p.magicLinkService == nil {
|
||||
return nil, fmt.Errorf("MagicLink service not configured")
|
||||
}
|
||||
@@ -308,7 +306,7 @@ func (p *DynamicAuthProvider) VerifyMagicLink(ctx context.Context, token, ip, us
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) VerifyReminderAuthToken(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) {
|
||||
func (p *Provider) VerifyReminderAuthToken(ctx context.Context, token, ip, userAgent string) (*providers.MagicLinkResult, error) {
|
||||
if p.magicLinkService == nil {
|
||||
return nil, fmt.Errorf("MagicLink service not configured")
|
||||
}
|
||||
@@ -325,7 +323,7 @@ func (p *DynamicAuthProvider) VerifyReminderAuthToken(ctx context.Context, token
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) CreateReminderAuthToken(ctx context.Context, email, docID string) (string, error) {
|
||||
func (p *Provider) CreateReminderAuthToken(ctx context.Context, email, docID string) (string, error) {
|
||||
if p.magicLinkService == nil {
|
||||
return "", fmt.Errorf("MagicLink service not configured")
|
||||
}
|
||||
@@ -334,7 +332,7 @@ func (p *DynamicAuthProvider) CreateReminderAuthToken(ctx context.Context, email
|
||||
|
||||
// === Internal helpers ===
|
||||
|
||||
func (p *DynamicAuthProvider) getOAuthConfig() *oauth2.Config {
|
||||
func (p *Provider) getOAuthConfig() *oauth2.Config {
|
||||
cfg := p.configProvider.GetConfig()
|
||||
|
||||
p.mu.RLock()
|
||||
@@ -372,14 +370,14 @@ func (p *DynamicAuthProvider) getOAuthConfig() *oauth2.Config {
|
||||
return p.cachedOAuthCfg
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) configMatches(cfg models.OIDCConfig) bool {
|
||||
func (p *Provider) configMatches(cfg models.OIDCConfig) bool {
|
||||
return p.cachedOIDCCfg.ClientID == cfg.ClientID &&
|
||||
p.cachedOIDCCfg.ClientSecret == cfg.ClientSecret &&
|
||||
p.cachedOIDCCfg.AuthURL == cfg.AuthURL &&
|
||||
p.cachedOIDCCfg.TokenURL == cfg.TokenURL
|
||||
}
|
||||
|
||||
func (p *DynamicAuthProvider) parseUserInfo(resp *http.Response) (*types.User, error) {
|
||||
func (p *Provider) parseUserInfo(resp *http.Response) (*types.User, error) {
|
||||
var rawUser map[string]interface{}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
@@ -436,4 +434,4 @@ func subtleConstantTimeCompare(a, b string) bool {
|
||||
}
|
||||
|
||||
// Compile-time interface check
|
||||
var _ providers.AuthProvider = (*DynamicAuthProvider)(nil)
|
||||
var _ providers.AuthProvider = (*Provider)(nil)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/web"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/providers"
|
||||
)
|
||||
|
||||
// SimpleAuthorizer is an authorization implementation based on a list of admin emails.
|
||||
@@ -30,13 +30,13 @@ func NewSimpleAuthorizer(adminEmails []string, onlyAdminCanCreate bool) *SimpleA
|
||||
}
|
||||
}
|
||||
|
||||
// IsAdmin implements web.Authorizer.
|
||||
// IsAdmin implements providers.Authorizer.
|
||||
func (a *SimpleAuthorizer) IsAdmin(_ context.Context, userEmail string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(userEmail))
|
||||
return a.adminEmails[normalized]
|
||||
}
|
||||
|
||||
// CanCreateDocument implements web.Authorizer.
|
||||
// CanCreateDocument implements providers.Authorizer.
|
||||
func (a *SimpleAuthorizer) CanCreateDocument(ctx context.Context, userEmail string) bool {
|
||||
if !a.onlyAdminCanCreate {
|
||||
return true
|
||||
@@ -45,4 +45,4 @@ func (a *SimpleAuthorizer) CanCreateDocument(ctx context.Context, userEmail stri
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ web.Authorizer = (*SimpleAuthorizer)(nil)
|
||||
var _ providers.Authorizer = (*SimpleAuthorizer)(nil)
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/crypto"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/logger"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/storage"
|
||||
webauth "github.com/btouchard/ackify-ce/backend/pkg/web/auth"
|
||||
|
||||
sdk "github.com/btouchard/shm/sdk/golang"
|
||||
)
|
||||
@@ -52,8 +53,10 @@ type Server struct {
|
||||
}
|
||||
|
||||
// ServerBuilder allows dependency injection for extensibility.
|
||||
// AuthProvider and Authorizer are REQUIRED and must be provided.
|
||||
// QuotaEnforcer and AuditLogger have sensible defaults for CE.
|
||||
// DB and TenantProvider are REQUIRED.
|
||||
// AuthProvider and Authorizer have sensible CE defaults (AuthProvider, SimpleAuthorizer).
|
||||
// QuotaEnforcer and AuditLogger have sensible CE defaults (NoLimit, LogOnly).
|
||||
// All technical services (I18n, Email, MagicLink, Reminder, Config) are created internally.
|
||||
type ServerBuilder struct {
|
||||
cfg *config.Config
|
||||
frontend embed.FS
|
||||
@@ -62,21 +65,22 @@ type ServerBuilder struct {
|
||||
// Core infrastructure (required)
|
||||
db *sql.DB
|
||||
tenantProvider tenant.Provider
|
||||
signer *crypto.Ed25519Signer
|
||||
|
||||
// Capability providers (auth and authorizer are REQUIRED)
|
||||
// Capability providers (all have CE defaults)
|
||||
authProvider AuthProvider
|
||||
authorizer Authorizer
|
||||
quotaEnforcer QuotaEnforcer
|
||||
auditLogger AuditLogger
|
||||
|
||||
// Optional infrastructure
|
||||
// Internal infrastructure (created by Build)
|
||||
signer *crypto.Ed25519Signer
|
||||
i18nService *i18n.I18n
|
||||
emailSender email.Sender
|
||||
emailRenderer *email.Renderer
|
||||
storageProvider storage.Provider
|
||||
sessionService *auth.SessionService
|
||||
|
||||
// Core services (created internally or injected)
|
||||
// Internal services (created by Build)
|
||||
magicLinkService *services.MagicLinkService
|
||||
signatureService *services.SignatureService
|
||||
documentService *services.DocumentService
|
||||
@@ -95,44 +99,18 @@ func NewServerBuilder(cfg *config.Config, frontend embed.FS, version string) *Se
|
||||
}
|
||||
}
|
||||
|
||||
// WithDB injects a database connection.
|
||||
// WithDB injects a database connection (REQUIRED).
|
||||
func (b *ServerBuilder) WithDB(db *sql.DB) *ServerBuilder {
|
||||
b.db = db
|
||||
return b
|
||||
}
|
||||
|
||||
// WithTenantProvider injects a tenant provider.
|
||||
// WithTenantProvider injects a tenant provider (REQUIRED).
|
||||
func (b *ServerBuilder) WithTenantProvider(tp tenant.Provider) *ServerBuilder {
|
||||
b.tenantProvider = tp
|
||||
return b
|
||||
}
|
||||
|
||||
// WithSigner injects a cryptographic signer.
|
||||
func (b *ServerBuilder) WithSigner(signer *crypto.Ed25519Signer) *ServerBuilder {
|
||||
b.signer = signer
|
||||
return b
|
||||
}
|
||||
|
||||
// WithI18nService injects an i18n service.
|
||||
func (b *ServerBuilder) WithI18nService(i18n *i18n.I18n) *ServerBuilder {
|
||||
b.i18nService = i18n
|
||||
return b
|
||||
}
|
||||
|
||||
// WithEmailSender injects an email sender.
|
||||
func (b *ServerBuilder) WithEmailSender(sender email.Sender) *ServerBuilder {
|
||||
b.emailSender = sender
|
||||
return b
|
||||
}
|
||||
|
||||
// WithEmailRenderer injects an email renderer.
|
||||
func (b *ServerBuilder) WithEmailRenderer(renderer *email.Renderer) *ServerBuilder {
|
||||
b.emailRenderer = renderer
|
||||
return b
|
||||
}
|
||||
|
||||
// === Capability Providers ===
|
||||
|
||||
// WithAuthProvider injects an authentication provider (REQUIRED).
|
||||
func (b *ServerBuilder) WithAuthProvider(provider AuthProvider) *ServerBuilder {
|
||||
b.authProvider = provider
|
||||
@@ -157,62 +135,31 @@ func (b *ServerBuilder) WithAuditLogger(logger AuditLogger) *ServerBuilder {
|
||||
return b
|
||||
}
|
||||
|
||||
// WithMagicLinkService injects a magic link service.
|
||||
func (b *ServerBuilder) WithMagicLinkService(service *services.MagicLinkService) *ServerBuilder {
|
||||
b.magicLinkService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// WithSignatureService injects a signature service.
|
||||
func (b *ServerBuilder) WithSignatureService(service *services.SignatureService) *ServerBuilder {
|
||||
b.signatureService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// WithDocumentService injects a document service.
|
||||
func (b *ServerBuilder) WithDocumentService(service *services.DocumentService) *ServerBuilder {
|
||||
b.documentService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// WithAdminService injects an admin service.
|
||||
func (b *ServerBuilder) WithAdminService(service *services.AdminService) *ServerBuilder {
|
||||
b.adminService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// WithWebhookService injects a webhook service.
|
||||
func (b *ServerBuilder) WithWebhookService(service *services.WebhookService) *ServerBuilder {
|
||||
b.webhookService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// WithReminderService injects a reminder service.
|
||||
func (b *ServerBuilder) WithReminderService(service *services.ReminderAsyncService) *ServerBuilder {
|
||||
b.reminderService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// WithConfigService injects a configuration service.
|
||||
func (b *ServerBuilder) WithConfigService(service *services.ConfigService) *ServerBuilder {
|
||||
b.configService = service
|
||||
return b
|
||||
}
|
||||
|
||||
// Build constructs the server with all dependencies.
|
||||
func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
|
||||
if err := b.validateProviders(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.setDefaultProviders()
|
||||
|
||||
if err := b.initializeInfrastructure(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repos := b.createRepositories()
|
||||
|
||||
// Initialize services that depend on repos
|
||||
if err := b.initializeConfigService(ctx, repos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.initializeMagicLinkService(repos)
|
||||
b.initializeSessionService(repos)
|
||||
|
||||
// Now we can set default providers (they depend on services above)
|
||||
b.setDefaultProviders()
|
||||
|
||||
b.initializeCoreServices(repos)
|
||||
b.initializeReminderService(repos)
|
||||
|
||||
if err := b.initializeTelemetry(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -227,10 +174,7 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.initializeCoreServices(repos)
|
||||
b.initializeConfigService(ctx, repos)
|
||||
magicLinkWorker := b.initializeMagicLinkCleanupWorker(ctx)
|
||||
b.initializeReminderService(repos)
|
||||
|
||||
sessionWorker, err := b.initializeSessionWorker(ctx, repos)
|
||||
if err != nil {
|
||||
@@ -263,17 +207,29 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
|
||||
|
||||
// validateProviders checks that required providers are set.
|
||||
func (b *ServerBuilder) validateProviders() error {
|
||||
if b.authProvider == nil {
|
||||
return errors.New("authProvider is required: use WithAuthProvider()")
|
||||
if b.db == nil {
|
||||
return errors.New("database is required: use WithDB()")
|
||||
}
|
||||
if b.authorizer == nil {
|
||||
return errors.New("authorizer is required: use WithAuthorizer()")
|
||||
if b.tenantProvider == nil {
|
||||
return errors.New("tenantProvider is required: use WithTenantProvider()")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDefaultProviders sets default implementations for optional providers.
|
||||
// Must be called AFTER initializeConfigService, initializeMagicLinkService, and initializeSessionService.
|
||||
func (b *ServerBuilder) setDefaultProviders() {
|
||||
if b.authProvider == nil {
|
||||
b.authProvider = webauth.NewAuthProvider(webauth.ProviderConfig{
|
||||
ConfigProvider: b.configService,
|
||||
SessionService: b.sessionService,
|
||||
MagicLinkService: b.magicLinkService,
|
||||
BaseURL: b.cfg.App.BaseURL,
|
||||
})
|
||||
}
|
||||
if b.authorizer == nil {
|
||||
b.authorizer = webauth.NewSimpleAuthorizer(b.cfg.App.AdminEmails, b.cfg.App.OnlyAdminCanCreate)
|
||||
}
|
||||
if b.quotaEnforcer == nil {
|
||||
b.quotaEnforcer = NewNoLimitQuotaEnforcer()
|
||||
}
|
||||
@@ -282,19 +238,38 @@ func (b *ServerBuilder) setDefaultProviders() {
|
||||
}
|
||||
}
|
||||
|
||||
// initializeInfrastructure initializes signer and storage provider.
|
||||
// i18n and emailSender are expected to be injected from main.go.
|
||||
// initializeInfrastructure initializes signer, i18n, email and storage.
|
||||
func (b *ServerBuilder) initializeInfrastructure() error {
|
||||
var err error
|
||||
|
||||
if b.signer == nil {
|
||||
b.signer, err = crypto.NewEd25519Signer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
// Signer
|
||||
b.signer, err = crypto.NewEd25519Signer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize signer: %w", err)
|
||||
}
|
||||
|
||||
if b.storageProvider == nil && b.cfg.Storage.IsEnabled() {
|
||||
// I18n
|
||||
b.i18nService, err = i18n.NewI18n(getLocalesDir())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize i18n: %w", err)
|
||||
}
|
||||
|
||||
// Email (only if SMTP is configured)
|
||||
if b.cfg.Mail.Host != "" {
|
||||
b.emailRenderer = email.NewRenderer(
|
||||
getTemplatesDir(),
|
||||
b.cfg.App.BaseURL,
|
||||
b.cfg.App.Organisation,
|
||||
b.cfg.Mail.FromName,
|
||||
b.cfg.Mail.From,
|
||||
b.cfg.Mail.DefaultLocale,
|
||||
b.i18nService,
|
||||
)
|
||||
b.emailSender = email.NewSMTPSender(b.cfg.Mail, b.emailRenderer)
|
||||
}
|
||||
|
||||
// Storage
|
||||
if b.cfg.Storage.IsEnabled() {
|
||||
provider, err := storage.NewProvider(b.cfg.Storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage provider: %w", err)
|
||||
@@ -319,6 +294,7 @@ type repositories struct {
|
||||
webhookDelivery *database.WebhookDeliveryRepository
|
||||
oauthSession *database.OAuthSessionRepository
|
||||
config *database.ConfigRepository
|
||||
magicLink services.MagicLinkRepository
|
||||
}
|
||||
|
||||
// createRepositories creates all repository instances.
|
||||
@@ -333,6 +309,7 @@ func (b *ServerBuilder) createRepositories() *repositories {
|
||||
webhookDelivery: database.NewWebhookDeliveryRepository(b.db, b.tenantProvider),
|
||||
oauthSession: database.NewOAuthSessionRepository(b.db, b.tenantProvider),
|
||||
config: database.NewConfigRepository(b.db, b.tenantProvider),
|
||||
magicLink: database.NewMagicLinkRepository(b.db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,29 +372,51 @@ func (b *ServerBuilder) initializeEmailWorker(ctx context.Context, repos *reposi
|
||||
|
||||
// initializeCoreServices initializes signature, document, admin, and webhook services.
|
||||
func (b *ServerBuilder) initializeCoreServices(repos *repositories) {
|
||||
if b.signatureService == nil {
|
||||
b.signatureService = services.NewSignatureService(repos.signature, repos.document, b.signer)
|
||||
b.signatureService.SetChecksumConfig(&b.cfg.Checksum)
|
||||
}
|
||||
if b.documentService == nil {
|
||||
b.documentService = services.NewDocumentService(repos.document, repos.expectedSigner, &b.cfg.Checksum)
|
||||
}
|
||||
if b.adminService == nil {
|
||||
b.adminService = services.NewAdminService(repos.document, repos.expectedSigner)
|
||||
}
|
||||
if b.webhookService == nil {
|
||||
b.webhookService = services.NewWebhookService(repos.webhook, repos.webhookDelivery)
|
||||
}
|
||||
b.signatureService = services.NewSignatureService(repos.signature, repos.document, b.signer)
|
||||
b.signatureService.SetChecksumConfig(&b.cfg.Checksum)
|
||||
b.documentService = services.NewDocumentService(repos.document, repos.expectedSigner, &b.cfg.Checksum)
|
||||
b.adminService = services.NewAdminService(repos.document, repos.expectedSigner)
|
||||
b.webhookService = services.NewWebhookService(repos.webhook, repos.webhookDelivery)
|
||||
}
|
||||
|
||||
// initializeConfigService is a no-op when configService is injected from main.go.
|
||||
// Kept for API compatibility.
|
||||
func (b *ServerBuilder) initializeConfigService(_ context.Context, _ *repositories) {
|
||||
// configService is expected to be injected and initialized from main.go
|
||||
// initializeConfigService creates and initializes the configuration service.
|
||||
func (b *ServerBuilder) initializeConfigService(ctx context.Context, repos *repositories) error {
|
||||
encryptionKey := b.cfg.OAuth.CookieSecret
|
||||
b.configService = services.NewConfigService(repos.config, b.cfg, encryptionKey)
|
||||
|
||||
// Initialize config from DB or ENV
|
||||
err := tenant.WithTenantContextFromProvider(ctx, b.db, b.tenantProvider, func(txCtx context.Context) error {
|
||||
return b.configService.Initialize(txCtx)
|
||||
})
|
||||
if err != nil {
|
||||
logger.Logger.Warn("Failed to initialize config service, using ENV config", "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initializeMagicLinkService creates the magic link service.
|
||||
func (b *ServerBuilder) initializeMagicLinkService(repos *repositories) {
|
||||
b.magicLinkService = services.NewMagicLinkService(services.MagicLinkServiceConfig{
|
||||
Repository: repos.magicLink,
|
||||
EmailSender: b.emailSender,
|
||||
I18n: b.i18nService,
|
||||
BaseURL: b.cfg.App.BaseURL,
|
||||
AppName: b.cfg.App.Organisation,
|
||||
RateLimitPerEmail: b.cfg.Auth.MagicLinkRateLimitEmail,
|
||||
RateLimitPerIP: b.cfg.Auth.MagicLinkRateLimitIP,
|
||||
})
|
||||
}
|
||||
|
||||
// initializeSessionService creates the session service for auth.
|
||||
func (b *ServerBuilder) initializeSessionService(repos *repositories) {
|
||||
b.sessionService = auth.NewSessionService(auth.SessionServiceConfig{
|
||||
CookieSecret: b.cfg.OAuth.CookieSecret,
|
||||
SecureCookies: b.cfg.App.SecureCookies,
|
||||
SessionRepo: repos.oauthSession,
|
||||
})
|
||||
}
|
||||
|
||||
// initializeMagicLinkCleanupWorker starts the cleanup worker for expired magic link tokens.
|
||||
// magicLinkService is expected to be injected from main.go.
|
||||
func (b *ServerBuilder) initializeMagicLinkCleanupWorker(ctx context.Context) *workers.MagicLinkCleanupWorker {
|
||||
magicLinkWorker := workers.NewMagicLinkCleanupWorker(b.magicLinkService, 1*time.Hour, b.db, b.tenantProvider)
|
||||
go magicLinkWorker.Start(ctx)
|
||||
@@ -426,16 +425,14 @@ func (b *ServerBuilder) initializeMagicLinkCleanupWorker(ctx context.Context) *w
|
||||
|
||||
// initializeReminderService initializes reminder service.
|
||||
func (b *ServerBuilder) initializeReminderService(repos *repositories) {
|
||||
if b.reminderService == nil {
|
||||
b.reminderService = services.NewReminderAsyncService(
|
||||
repos.expectedSigner,
|
||||
repos.reminder,
|
||||
repos.emailQueue,
|
||||
b.magicLinkService,
|
||||
b.i18nService,
|
||||
b.cfg.App.BaseURL,
|
||||
)
|
||||
}
|
||||
b.reminderService = services.NewReminderAsyncService(
|
||||
repos.expectedSigner,
|
||||
repos.reminder,
|
||||
repos.emailQueue,
|
||||
b.magicLinkService,
|
||||
b.i18nService,
|
||||
b.cfg.App.BaseURL,
|
||||
)
|
||||
}
|
||||
|
||||
// initializeSessionWorker initializes OAuth session cleanup worker.
|
||||
@@ -465,7 +462,7 @@ func (b *ServerBuilder) buildRouter(repos *repositories, whPublisher *services.W
|
||||
DB: b.db,
|
||||
TenantProvider: b.tenantProvider,
|
||||
|
||||
// Capability providers (AuthProvider handles OIDC + MagicLink dynamically)
|
||||
// Capability providers (Provider handles OIDC + MagicLink dynamically)
|
||||
AuthProvider: b.authProvider,
|
||||
Authorizer: b.authorizer,
|
||||
SignatureService: b.signatureService,
|
||||
|
||||
Reference in New Issue
Block a user