ultisuite-backend/internal/server/bootstrap.go
R3D347HR4Y 8bbc539d77 feat(auth): implement flow completion and rate limiting for authentication flows
- Added a new handler for completing authentication flows, including session validation and cookie management.
- Implemented flow rate limiting to restrict the number of flow start requests per client IP.
- Enhanced flow session management with Redis support for persistent session storage.
- Updated existing handlers to integrate the new flow completion logic and error handling for various session states.
- Introduced unit tests for the new flow completion and rate limiting functionalities to ensure reliability.
2026-06-20 01:09:42 +02:00

527 lines
20 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
aiapi "github.com/ultisuite/ulti-backend/internal/api/ai"
authapi "github.com/ultisuite/ulti-backend/internal/api/auth"
"github.com/ultisuite/ulti-backend/internal/api/admin"
"github.com/ultisuite/ulti-backend/internal/api/calendar"
"github.com/ultisuite/ulti-backend/internal/api/contacts"
"github.com/ultisuite/ulti-backend/internal/api/devices"
"github.com/ultisuite/ulti-backend/internal/api/docs"
"github.com/ultisuite/ulti-backend/internal/api/drive"
mailapi "github.com/ultisuite/ulti-backend/internal/api/mail"
migrationapi "github.com/ultisuite/ulti-backend/internal/api/migration"
"github.com/ultisuite/ulti-backend/internal/api/mail/drivebridge"
"github.com/ultisuite/ulti-backend/internal/api/mail/sendguard"
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
"github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/office"
"github.com/ultisuite/ulti-backend/internal/api/richtext"
"github.com/ultisuite/ulti-backend/internal/api/ultidraw"
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
usersapi "github.com/ultisuite/ulti-backend/internal/api/users"
"github.com/ultisuite/ulti-backend/internal/automation"
"github.com/ultisuite/ulti-backend/internal/authentik"
"github.com/ultisuite/ulti-backend/internal/auth"
"github.com/ultisuite/ulti-backend/internal/contacts/discovery"
"github.com/ultisuite/ulti-backend/internal/config"
"github.com/ultisuite/ulti-backend/internal/filescan"
"github.com/ultisuite/ulti-backend/internal/httpcors"
mailcredentials "github.com/ultisuite/ulti-backend/internal/mail/credentials"
imapsync "github.com/ultisuite/ulti-backend/internal/mail/imap"
mailoauth "github.com/ultisuite/ulti-backend/internal/mail/oauth"
"github.com/ultisuite/ulti-backend/internal/mail/hosted"
"github.com/ultisuite/ulti-backend/internal/mail/stalwart"
"github.com/ultisuite/ulti-backend/internal/migration"
"github.com/ultisuite/ulti-backend/internal/provision"
"github.com/ultisuite/ulti-backend/internal/mail/rules"
"github.com/ultisuite/ulti-backend/internal/mail/smtp"
mailstorage "github.com/ultisuite/ulti-backend/internal/mail/storage"
"github.com/ultisuite/ulti-backend/internal/mail/webhooks"
"github.com/ultisuite/ulti-backend/internal/meet"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/observability"
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
"github.com/ultisuite/ulti-backend/internal/photos"
"github.com/ultisuite/ulti-backend/internal/push"
"github.com/ultisuite/ulti-backend/internal/realtime"
"github.com/ultisuite/ulti-backend/internal/search"
"github.com/ultisuite/ulti-backend/internal/securityaudit"
)
// App holds wired dependencies and the HTTP router.
type App struct {
Router chi.Router
Pool *pgxpool.Pool
Redis *redis.Client
VerifierHolder *auth.Holder
MailOAuth *mailoauth.Service
ownsPool bool
ownsRedis bool
cancel context.CancelFunc
}
// New builds the application router and optional background workers.
func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
if cfg == nil {
return nil, fmt.Errorf("config is nil")
}
workerCtx, cancel := context.WithCancel(ctx)
pool := opts.Pool
ownsPool := false
if pool == nil {
var err error
pool, err = pgxpool.New(workerCtx, cfg.DatabaseURL)
if err != nil {
cancel()
return nil, fmt.Errorf("connect database: %w", err)
}
ownsPool = true
}
if !opts.SkipAuthentikProvisioner {
authentik.StartProvisioner(workerCtx, pool, cfg)
}
rdb := opts.Redis
ownsRedis := false
if rdb == nil {
rdb = redis.NewClient(&redis.Options{
Addr: cfg.KeyDBAddr,
Password: cfg.KeyDBPassword,
DB: cfg.KeyDBDB,
})
ownsRedis = true
}
minioClient, err := minio.New(cfg.RustFSEndpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.RustFSAccessKey, cfg.RustFSSecretKey, ""),
Secure: cfg.RustFSUseSSL,
})
if err != nil {
cancel()
if ownsPool {
pool.Close()
}
if ownsRedis {
_ = rdb.Close()
}
return nil, fmt.Errorf("rustfs client: %w", err)
}
attachmentStorage := mailstorage.NewClient(minioClient, cfg.MailAttachmentsBucket)
if err := attachmentStorage.EnsureBucket(workerCtx); err != nil {
slog.Warn("mail attachments bucket check failed", "error", err)
}
verifierHolder := opts.VerifierHolder
if verifierHolder == nil {
verifier, verr := auth.NewVerifierWithRetry(workerCtx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain, 45, 2*time.Second)
if verr != nil {
slog.Warn("OIDC verifier not available at startup (Authentik may still be starting)", "error", verr)
}
verifierHolder = auth.NewHolder(verifier)
if !verifierHolder.Ready() && cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" {
pending := auth.NewHolderPending(cfg.OIDCIssuer, cfg.OIDCClientID, cfg.Domain)
verifierHolder = pending
verifierHolder.StartBackgroundRetry(workerCtx, 5*time.Second)
}
}
if cfg.IsProduction() {
if cfg.OIDCIssuer == "" || cfg.OIDCClientID == "" {
cancel()
closeOwned(ownsPool, pool, ownsRedis, rdb)
return nil, fmt.Errorf("missing required OIDC configuration in production")
}
if !verifierHolder.Ready() {
cancel()
closeOwned(ownsPool, pool, ownsRedis, rdb)
return nil, fmt.Errorf("OIDC verifier initialization failed in production")
}
if err := cfg.ValidateSecretRotation(); err != nil {
cancel()
closeOwned(ownsPool, pool, ownsRedis, rdb)
return nil, fmt.Errorf("secret rotation policy: %w", err)
}
} else if err := cfg.ValidateSecretRotation(); err != nil {
slog.Warn("secret rotation policy warning", "error", err)
}
credentialManager, err := mailcredentials.NewManager(cfg.MailCredentialKeys, cfg.MailActiveCredentialKeyID)
if err != nil {
cancel()
closeOwned(ownsPool, pool, ownsRedis, rdb)
return nil, fmt.Errorf("mail credential encryption: %w", err)
}
auditLogger := securityaudit.NewLogger(pool)
var ncClient *nextcloud.Client
if cfg.NextcloudEnabled {
ncClient = nextcloud.NewClient(cfg.NextcloudURL, cfg.NCAdminUser, cfg.NCAdminPass).
WithPublicURL(cfg.NextcloudPublicURL).
WithDrivePublicURL(cfg.DrivePublicURL).
WithDAVCredentials(nextcloud.NewDAVCredentialStore(pool, credentialManager))
slog.Info("nextcloud enabled", "url", cfg.NextcloudURL, "public_url", cfg.NextcloudPublicURL, "drive_public_url", cfg.DrivePublicURL)
}
var meetCfg *meet.Config
if cfg.JitsiEnabled {
meetCfg = meet.NewConfig(cfg.JitsiAppID, cfg.JitsiAppSecret, cfg.Domain)
slog.Info("jitsi meet enabled", "domain", cfg.JitsiDomain)
}
var photosClient *photos.Client
if cfg.ImmichEnabled {
photosClient = photos.NewClient(cfg.ImmichAPIURL)
slog.Info("immich photos enabled", "url", cfg.ImmichAPIURL)
}
hub := realtime.NewHub(verifierHolder, pool)
healthChecker := observability.NewHealthChecker(cfg, pool, rdb)
pushDispatcher := push.NewDispatcher(pool, push.Config{
FCMServiceAccountJSON: cfg.FCMServiceAccountJSON,
FCMProjectID: cfg.FCMProjectID,
APNSPrivateKey: cfg.APNSPrivateKey,
APNSKeyID: cfg.APNSKeyID,
APNSTeamID: cfg.APNSTeamID,
APNSBundleID: cfg.APNSBundleID,
APNSProduction: cfg.APNSProduction,
})
hookExec := webhooks.NewExecutor(pool)
rulesEngine := rules.NewEngineWithWebhooks(pool, hookExec)
autoDispatcher := automation.NewDispatcher(pool, rulesEngine, hookExec)
oauthRedirect := cfg.MailOAuthRedirectURL
if oauthRedirect == "" {
oauthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/mail/accounts/oauth/callback", cfg.Port)
if cfg.Domain != "" && cfg.Domain != "localhost" {
oauthRedirect = fmt.Sprintf("https://%s/api/v1/mail/accounts/oauth/callback", cfg.Domain)
}
}
mailOAuthSvc := mailoauth.NewService(mailoauth.Config{
GoogleClientID: cfg.MailGoogleOAuthClientID,
GoogleClientSecret: cfg.MailGoogleOAuthClientSecret,
MicrosoftClientID: cfg.MailMicrosoftOAuthClientID,
MicrosoftSecret: cfg.MailMicrosoftOAuthSecret,
MicrosoftTenant: cfg.MailMicrosoftOAuthTenant,
RedirectURL: oauthRedirect,
}, rdb)
stlwClient := stalwart.NewClient(stalwart.Config{
Enabled: cfg.StalwartEnabled,
BaseURL: cfg.StalwartAPIURL,
APIKey: cfg.StalwartAPIKey,
IMAPHost: cfg.StalwartIMAPHost,
IMAPPort: cfg.StalwartIMAPPort,
IMAPTLS: cfg.StalwartIMAPTLS,
SMTPHost: cfg.StalwartSMTPHost,
SMTPPort: cfg.StalwartSMTPPort,
SMTPTLS: cfg.StalwartSMTPTLS,
})
hostedSvc := hosted.NewService(pool, stlwClient, credentialManager)
if cfg.PlatformMailDomain != "" {
if _, err := hostedSvc.EnsurePlatformDomain(workerCtx, cfg.PlatformMailDomain); err != nil {
slog.Warn("platform mail domain bootstrap failed", "domain", cfg.PlatformMailDomain, "error", err)
}
}
migrationOAuthRedirect := cfg.MigrationOAuthRedirectURL
if migrationOAuthRedirect == "" {
migrationOAuthRedirect = fmt.Sprintf("http://localhost:%d/api/v1/migration/oauth/callback", cfg.Port)
if cfg.Domain != "" && cfg.Domain != "localhost" {
migrationOAuthRedirect = fmt.Sprintf("https://%s/api/v1/migration/oauth/callback", cfg.Domain)
}
}
migrationOAuthSvc := migration.NewOAuthService(migration.OAuthConfig{
GoogleClientID: firstNonEmpty(cfg.MigrationGoogleOAuthClientID, cfg.MailGoogleOAuthClientID),
GoogleClientSecret: firstNonEmpty(cfg.MigrationGoogleOAuthClientSecret, cfg.MailGoogleOAuthClientSecret),
MicrosoftClientID: firstNonEmpty(cfg.MigrationMicrosoftOAuthClientID, cfg.MailMicrosoftOAuthClientID),
MicrosoftSecret: firstNonEmpty(cfg.MigrationMicrosoftOAuthSecret, cfg.MailMicrosoftOAuthSecret),
MicrosoftTenant: firstNonEmpty(cfg.MigrationMicrosoftOAuthTenant, cfg.MailMicrosoftOAuthTenant),
RedirectURL: migrationOAuthRedirect,
}, rdb)
migrationSvc := migration.NewService(pool, rdb, credentialManager, hostedSvc, migrationOAuthSvc)
migrationSvc.SetCutoverConfig(migration.CutoverConfig{
ExpectedMXHosts: migration.ParseCutoverMXHosts(
cfg.MigrationCutoverMXHosts,
cfg.PlatformMailDomain,
cfg.StalwartIMAPHost,
),
RequireMX: cfg.MigrationCutoverRequireMX,
})
googleDWD, err := migration.NewGoogleDWD(cfg.MigrationGoogleServiceAccountJSON)
if err != nil {
return nil, fmt.Errorf("google dwd: %w", err)
}
microsoftApp, err := migration.NewMicrosoftApp(migration.MicrosoftAppConfig{
ClientID: firstNonEmpty(cfg.MigrationMicrosoftOAuthClientID, cfg.MailMicrosoftOAuthClientID),
ClientSecret: firstNonEmpty(cfg.MigrationMicrosoftOAuthSecret, cfg.MailMicrosoftOAuthSecret),
DefaultTenant: firstNonEmpty(cfg.MigrationMicrosoftOAuthTenant, cfg.MailMicrosoftOAuthTenant),
})
if err != nil {
return nil, fmt.Errorf("microsoft app: %w", err)
}
migration.ConfigureRateLimit(migration.RateLimitConfig{
MaxRetries: cfg.MigrationRateLimitMaxRetries,
BaseDelay: cfg.MigrationRateLimitBaseDelay,
MaxDelay: cfg.MigrationRateLimitMaxDelay,
})
migration.ConfigureImportBatch(migration.ImportBatchConfig{
Mail: cfg.MigrationImportBatchSize,
Drive: cfg.MigrationDriveBatchSize,
})
if !opts.WithoutWorkers {
go migration.NewWorker(
pool, migrationSvc, migrationOAuthSvc, credentialManager, googleDWD, microsoftApp, ncClient,
attachmentStorage, cfg.MailAttachmentsBucket,
migration.WorkerConfig{
Concurrency: cfg.MigrationWorkerConcurrency,
JobLimit: cfg.MigrationWorkerJobLimit,
},
).Start(workerCtx, cfg.MigrationWorkerInterval)
}
orgPolicyLoader := orgpolicy.NewLoader(pool, cfg)
fileScanner := filescan.NewScanner(orgPolicyLoader, slog.Default())
var syncWorker *imapsync.SyncWorker
if !opts.WithoutWorkers {
syncWorker = imapsync.NewSyncWorker(pool, cfg.MailSyncInterval, credentialManager, mailOAuthSvc, imapsync.SyncDeps{
Storage: attachmentStorage,
AttachBucket: cfg.MailAttachmentsBucket,
Automation: autoDispatcher,
Hub: hub,
FileScanner: fileScanner,
Push: pushDispatcher,
})
go syncWorker.Start(workerCtx)
}
sender := smtp.NewSender(pool, credentialManager, mailOAuthSvc)
smtpCircuit := smtp.NewCircuitBreaker(cfg.MailSMTPCircuitFailures, cfg.MailSMTPCircuitCooldown)
guardedSender := smtp.NewGuardedSender(sender, smtpCircuit)
if !opts.WithoutWorkers {
go smtp.NewOutboxProcessor(
pool,
guardedSender,
cfg.MailOutboxInterval,
cfg.MailOutboxMaxRetries,
smtp.WithAttachmentLoader(&smtp.StorageAttachmentLoader{Client: attachmentStorage}),
).Start(workerCtx)
}
sendRateLimiter := sendguard.NewRateLimiter(cfg.MailSendRatePerMinute, cfg.MailSendBurst)
mailHandler := mailapi.NewHandler(pool, auditLogger, credentialManager, attachmentStorage, cfg.MailAttachmentsBucket, sendRateLimiter, mailOAuthSvc, cfg.MailAppURL, sender)
mailHandler.SetHostedService(hostedSvc)
mailHandler.SetHostedPlatformDomain(cfg.PlatformMailDomain)
migrationHandler := migrationapi.NewHandler(migrationSvc, migrationOAuthSvc, cfg.MailAppURL)
provisionHandler := provision.NewHandler(cfg.ProvisionWebhookSecret, cfg.PlatformMailDomain, hostedSvc, ncClient, pool)
mailHandler.SetFileScanner(fileScanner)
if syncWorker != nil {
mailHandler.SetAccountSync(syncWorker)
}
r := chi.NewRouter()
r.Use(httpcors.Middleware(cfg))
r.Use(middleware.TraceID)
r.Use(observability.HTTPMetrics)
r.Use(middleware.Logging)
r.Mount("/api/docs", docs.NewHandler().Routes())
r.Get("/healthz", func(w http.ResponseWriter, req *http.Request) {
report := healthChecker.Check(req.Context())
statusCode := http.StatusOK
if report.Status != "ok" {
statusCode = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(report)
})
r.Handle("/metrics", promhttp.Handler())
r.Get("/ws", hub.HandleWS)
r.Get("/api/v1/mail/accounts/oauth/callback", mailHandler.OAuthCallback)
r.Get("/api/v1/migration/oauth/callback", migrationHandler.OAuthCallback)
r.Get("/api/v1/mail/addresses/check", mailHandler.CheckAddressAvailability)
r.Get("/api/v1/migration/invite", migrationHandler.GetInvite)
r.Post("/internal/provision/user", provisionHandler.ProvisionUser)
r.Mount("/api/v1/auth", authapi.NewHandler(cfg.AuthentikAPIURL, cfg.MailAppURL, rdb).Routes())
var driveHandler *drive.Handler
var driveSvc *drive.Service
var contactsHandler *contacts.Handler
if ncClient != nil {
driveSvc = drive.NewService(ncClient, hub, pool)
driveSvc.SetAutomation(autoDispatcher)
driveSvc.SetFileScanner(fileScanner)
driveHandler = drive.NewHandlerWithService(driveSvc)
mailHandler.SetDriveUploader(&drivebridge.Bridge{Svc: driveSvc})
contactsHandler = contacts.NewHandler(ncClient, pool)
contactsHandler.SetAutomation(autoDispatcher)
}
if ncClient != nil && cfg.OnlyOfficeEnabled && driveSvc != nil {
officeSvc := office.NewService(ncClient, office.Config{
Enabled: true,
DocumentURL: cfg.OnlyOfficeURL,
PublicURL: cfg.OnlyOfficePublicURL,
APIInternalURL: cfg.OnlyOfficeAPIInternalURL,
JWTSecret: cfg.OnlyOfficeJWTSecret,
})
officeHandler := office.NewHandler(officeSvc, driveSvc)
r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)))
driveHandler.SetPublicOffice(officeHandler)
}
if ncClient != nil && cfg.RichTextEnabled && driveSvc != nil {
rtSvc := richtext.NewService(ncClient, richtext.Config{
Enabled: true,
HocuspocusPublicURL: cfg.HocuspocusPublicURL,
HocuspocusSecret: cfg.HocuspocusSecret,
APIInternalURL: cfg.OnlyOfficeAPIInternalURL,
StorageMode: cfg.RichTextStorageMode,
ExportMirrorFormat: cfg.RichTextExportMirror,
}, driveSvc)
rtHandler := richtext.NewHandler(rtSvc, driveSvc)
r.Mount("/api/v1/richtext", rtHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)))
driveHandler.SetPublicRichText(rtHandler)
udSvc := ultidraw.NewService(ncClient, ultidraw.Config{
Enabled: true,
HocuspocusPublicURL: cfg.HocuspocusPublicURL,
HocuspocusSecret: cfg.HocuspocusSecret,
APIInternalURL: cfg.OnlyOfficeAPIInternalURL,
}, driveSvc)
udHandler := ultidraw.NewHandler(udSvc)
r.Mount("/api/v1/ultidraw", udHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)))
driveHandler.SetPublicUltidraw(udHandler)
}
if driveHandler != nil {
r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes())
}
aiHandler := aiapi.NewHandler(pool, cfg, ncClient, verifierHolder)
r.Mount("/api/v1/ai", aiHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)))
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader))
r.Use(middleware.EnforceApiTokenPolicy())
r.Mount("/api/v1/users", usersapi.NewHandler(pool, cfg).Routes())
r.Mount("/api/v1/devices", devices.NewHandler(pool).Routes())
adminHandler := admin.NewHandler(pool, auditLogger, cfg, ncClient)
adminHandler.SetHostedService(hostedSvc)
adminHandler.SetMigrationService(migrationSvc)
r.Mount("/api/v1/admin", adminHandler.Routes())
if driveHandler != nil {
r.Mount("/api/v1/drive", driveHandler.Routes())
}
r.Group(func(r chi.Router) {
r.Use(middleware.RequireFullAccount)
r.Mount("/api/v1/mail", mailHandler.Routes())
r.Mount("/api/v1/migration", migrationHandler.Routes())
searchHandler := search.NewHandler(pool, search.Options{
Nextcloud: ncClient,
Engine: cfg.SearchEngine,
MeilisearchURL: cfg.MeilisearchURL,
MeilisearchKey: cfg.MeilisearchKey,
MeilisearchIndex: cfg.MeilisearchIndex,
TypesenseURL: cfg.TypesenseURL,
TypesenseKey: cfg.TypesenseKey,
TypesenseCollection: cfg.TypesenseCollection,
})
r.Get("/api/v1/search", searchHandler.Search)
webDiscovery := discovery.NewService(pool)
if contactsHandler != nil && contactsHandler.Discovery() != nil {
webDiscovery = contactsHandler.Discovery()
}
r.Get("/api/v1/search/web", search.NewWebHandler(webDiscovery).Search)
if driveHandler != nil {
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg, orgPolicyLoader).Routes())
r.Mount("/api/v1/contacts", contactsHandler.Routes())
}
r.Mount("/api/v1/meet", meetapi.NewHandler(
meetCfg,
cfg.JitsiEnabled,
cfg.JitsiPublicURL,
orgPolicyLoader,
pool,
ncClient,
cfg.MeetTranscriptWebhookSecret,
).Routes())
if photosClient != nil {
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
}
})
})
slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect)
return &App{
Router: r,
Pool: pool,
Redis: rdb,
VerifierHolder: verifierHolder,
MailOAuth: mailOAuthSvc,
ownsPool: ownsPool,
ownsRedis: ownsRedis,
cancel: cancel,
}, nil
}
// Close stops workers and releases owned connections.
func (a *App) Close() {
if a == nil {
return
}
if a.cancel != nil {
a.cancel()
}
if a.ownsPool && a.Pool != nil {
a.Pool.Close()
}
if a.ownsRedis && a.Redis != nil {
_ = a.Redis.Close()
}
}
func closeOwned(ownsPool bool, pool *pgxpool.Pool, ownsRedis bool, rdb *redis.Client) {
if ownsPool && pool != nil {
pool.Close()
}
if ownsRedis && rdb != nil {
_ = rdb.Close()
}
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}