package main import ( "cmp" "context" "encoding/json" "fmt" "log" "os" "path/filepath" "strings" "time" "html/template" activitylog "github.com/abhinavxd/libredesk/internal/activity_log" "github.com/abhinavxd/libredesk/internal/ai" "github.com/abhinavxd/libredesk/internal/aiagent" auth_ "github.com/abhinavxd/libredesk/internal/auth" "github.com/abhinavxd/libredesk/internal/authz" "github.com/abhinavxd/libredesk/internal/autoassigner" "github.com/abhinavxd/libredesk/internal/automation" businesshours "github.com/abhinavxd/libredesk/internal/business_hours" "github.com/abhinavxd/libredesk/internal/colorlog" contextlink "github.com/abhinavxd/libredesk/internal/context_link" "github.com/abhinavxd/libredesk/internal/conversation" "github.com/abhinavxd/libredesk/internal/conversation/priority" "github.com/abhinavxd/libredesk/internal/conversation/status" "github.com/abhinavxd/libredesk/internal/csat" customAttribute "github.com/abhinavxd/libredesk/internal/custom_attribute" "github.com/abhinavxd/libredesk/internal/helpcenter" "github.com/abhinavxd/libredesk/internal/importer" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/email" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" "github.com/abhinavxd/libredesk/internal/macro" "github.com/abhinavxd/libredesk/internal/media" fs "github.com/abhinavxd/libredesk/internal/media/stores/localfs" "github.com/abhinavxd/libredesk/internal/media/stores/s3" notifier "github.com/abhinavxd/libredesk/internal/notification" emailnotifier "github.com/abhinavxd/libredesk/internal/notification/providers/email" "github.com/abhinavxd/libredesk/internal/oidc" "github.com/abhinavxd/libredesk/internal/ratelimit" "github.com/abhinavxd/libredesk/internal/report" "github.com/abhinavxd/libredesk/internal/role" "github.com/abhinavxd/libredesk/internal/search" "github.com/abhinavxd/libredesk/internal/setting" "github.com/abhinavxd/libredesk/internal/sla" "github.com/abhinavxd/libredesk/internal/ssrf" "github.com/abhinavxd/libredesk/internal/tag" "github.com/abhinavxd/libredesk/internal/team" tmpl "github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/user" "github.com/abhinavxd/libredesk/internal/view" "github.com/abhinavxd/libredesk/internal/webhook" "github.com/abhinavxd/libredesk/internal/ws" "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" kjson "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/env/v2" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/providers/rawbytes" "github.com/knadh/koanf/v2" "github.com/knadh/stuffbin" _ "github.com/lib/pq" "github.com/redis/go-redis/v9" flag "github.com/spf13/pflag" "github.com/volatiletech/null/v9" goredis "github.com/zerodha/fastcache/stores/goredis/v9" "github.com/zerodha/fastcache/v4" "github.com/zerodha/logf" ) // constants holds the app constants. type constants struct { AppBaseURL string FaviconURL string LogoURL string SiteName string UploadProvider string AllowedUploadFileExtensions []string MaxFileUploadSizeMB int } // Config loads config from files and environment variables into koanf. func initConfig(ko *koanf.Koanf) { for _, f := range ko.Strings("config") { log.Println("reading config file:", f) if err := ko.Load(file.Provider(f), toml.Parser()); err != nil { if os.IsNotExist(err) { log.Printf("WARNING: Config file not found. Continuing with defaults and environment variables.") continue } log.Fatalf("error loading config from file: %v.", err) } } // Load environment variables with `LIBREDESK_` prefix. ko.Load(env.Provider(".", env.Opt{ Prefix: "LIBREDESK_", TransformFunc: func(key, val string) (string, any) { // Transform the key. key = strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(key, "LIBREDESK_")), "__", ".") return key, val }, }), nil) } // validateConfig logs warnings/fatals for invalid config values. func validateConfig(ko *koanf.Koanf) { encKey := ko.MustString("app.encryption_key") if len(encKey) != 32 { log.Fatalf("encryption_key must be exactly 32 characters, got %d", len(encKey)) } // Warn if using sample config value. if encKey == sampleEncKey { colorlog.Red("WARNING: You are using the sample encryption_key from config.sample.toml. Change it immediately. Generate a secure key with `openssl rand -hex 16`") } } // initFlags initializes the commandline flags. func initFlags() { f := flag.NewFlagSet("config", flag.ContinueOnError) // Registering `--help` handler. f.Usage = func() { fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) f.PrintDefaults() } // Register the commandline flags and parse them. f.StringSlice("config", []string{"config.toml"}, "path to one or more config files (will be merged in order)") f.Bool("version", false, "show current version of the build") f.Bool("install", false, "setup database") f.Bool("idempotent-install", false, "run idempotent installation, i.e., skip installion if schema is already installed useful for the first time setup") f.Bool("yes", false, "skip confirmation prompt") f.Bool("upgrade", false, "upgrade the database schema") f.Bool("set-system-user-password", false, "set password for the system user") f.String("static-dir", "", "path to a directory with custom static files and templates to override the defaults") if err := f.Parse(os.Args[1:]); err != nil { log.Fatalf("loading flags: %v", err) } if err := ko.Load(posflag.Provider(f, ".", ko), nil); err != nil { log.Fatalf("loading config: %v", err) } } // initConstants initializes the app constants. func initConstants() *constants { return &constants{ AppBaseURL: ko.String("app.root_url"), FaviconURL: ko.String("app.favicon_url"), LogoURL: ko.String("app.logo_url"), SiteName: ko.String("app.site_name"), UploadProvider: ko.MustString("upload.provider"), AllowedUploadFileExtensions: ko.Strings("app.allowed_file_upload_extensions"), MaxFileUploadSizeMB: ko.Int("app.max_file_upload_size"), } } // initFS initializes the stuffbin FileSystem. If staticDir is set, files from // that directory are merged into the FS, overriding embedded defaults. func initFS(staticDir string) stuffbin.FileSystem { // Custom static dir mirrors the structure of the built-in static/ directory. staticFiles := []string{ "./:static", } // Get self executable path. path, err := os.Executable() if err != nil { log.Fatalf("error initializing FS: %v", err) } // Load embedded files in the executable. fs, err := stuffbin.UnStuff(path) if err != nil { if err == stuffbin.ErrNoID { // Running in local/dev mode, use the local filesystem. // Only include frontend dirs if they exist (frontend build is optional in dev). colorlog.Red("binary unstuff failed, using local filesystem for static files") files := []string{"i18n", "static"} for _, d := range []string{"frontend/dist/main", "frontend/dist/widget"} { if _, err := os.Stat(d); err == nil { files = append(files, d) } } fs, err = stuffbin.NewLocalFS("/", files...) if err != nil { log.Fatalf("error initializing local FS: %v", err) } } else { log.Fatalf("error initializing FS: %v", err) } } // Merge custom static files if a custom static dir is provided. if staticDir != "" { // Only include paths that exist in the custom dir. var sf []string for _, def := range staticFiles { src, _, _ := strings.Cut(def, ":") if _, err := os.Stat(filepath.Join(staticDir, src)); err == nil { sf = append(sf, def) } } if len(sf) > 0 { files := joinFSPaths(staticDir, sf) fLocal, err := stuffbin.NewLocalFS("/", files...) if err != nil { log.Fatalf("error loading custom static files from '%s': %v", staticDir, err) } if err := fs.Merge(fLocal); err != nil { log.Fatalf("error merging custom static files from '%s': %v", staticDir, err) } } } return fs } // joinFSPaths joins a root directory with stuffbin path specs (local:virtual). func joinFSPaths(root string, paths []string) []string { out := make([]string, 0, len(paths)) for _, p := range paths { f := strings.Split(p, ":") out = append(out, filepath.Join(root, f[0])+":"+f[1]) } return out } // loadSettings loads settings from the DB into Koanf map. func loadSettings(m *setting.Manager) { j, err := m.GetAllJSON() if err != nil { log.Fatalf("error parsing settings from DB: %v", err) } // Setting keys are dot separated, eg: app.favicon_url. Unflatten them into // nested maps {app: {favicon_url}}. var out map[string]any if err := json.Unmarshal(j, &out); err != nil { log.Fatalf("error unmarshalling settings from DB: %v", err) } if err := ko.Load(confmap.Provider(out, "."), nil); err != nil { log.Fatalf("error parsing settings from DB: %v", err) } } // initSettings inits setting manager. func initSettings(db *sqlx.DB) *setting.Manager { s, err := setting.New(setting.Opts{ DB: db, Lo: initLogger("settings"), EncryptionKey: ko.MustString("app.encryption_key"), }) if err != nil { log.Fatalf("error initializing setting manager: %v", err) } return s } // initUser inits user manager. func initUser(i18n *i18n.I18n, DB *sqlx.DB) *user.Manager { mgr, err := user.New(i18n, user.Opts{ DB: DB, Lo: initLogger("user_manager"), }) if err != nil { log.Fatalf("error initializing user manager: %v", err) } return mgr } // initConversations inits conversation manager. func initConversations( i18n *i18n.I18n, sla *sla.Manager, status *status.Manager, priority *priority.Manager, hub *ws.Hub, db *sqlx.DB, inboxStore *inbox.Manager, userStore *user.Manager, teamStore *team.Manager, mediaStore *media.Manager, settings *setting.Manager, csat *csat.Manager, automationEngine *automation.Engine, template *tmpl.Manager, webhook *webhook.Manager, dispatcher *notifier.Dispatcher, ) *conversation.Manager { continuityConfig := &conversation.ContinuityConfig{} if ko.Exists("conversation.continuity_scan_interval") { continuityConfig.BatchCheckInterval = ko.MustDuration("conversation.continuity_scan_interval") } c, err := conversation.New(hub, i18n, sla, status, priority, inboxStore, userStore, teamStore, mediaStore, settings, csat, automationEngine, template, webhook, dispatcher, conversation.Opts{ DB: db, Lo: initLogger("conversation_manager"), OutgoingMessageQueueSize: ko.MustInt("message.outgoing_queue_size"), IncomingMessageQueueSize: ko.MustInt("message.incoming_queue_size"), ContinuityConfig: continuityConfig, SubjectRefFormat: ko.String("conversation.subject_ref_format"), }) if err != nil { log.Fatalf("error initializing conversation manager: %v", err) } return c } // initTag inits tag manager. func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager { var lo = initLogger("tag_manager") mgr, err := tag.New(tag.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing tags: %v", err) } return mgr } // initViews inits view manager. func initView(db *sqlx.DB, i18n *i18n.I18n) *view.Manager { var lo = initLogger("view_manager") m, err := view.New(view.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing view manager: %v", err) } return m } // initMacro inits macro manager. func initMacro(db *sqlx.DB, i18n *i18n.I18n) *macro.Manager { var lo = initLogger("macro") m, err := macro.New(macro.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing macro manager: %v", err) } return m } // initBusinessHours inits business hours manager. func initBusinessHours(db *sqlx.DB, i18n *i18n.I18n) *businesshours.Manager { var lo = initLogger("business-hours") m, err := businesshours.New(businesshours.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing business hours manager: %v", err) } return m } // initSLA inits SLA manager. func initSLA(db *sqlx.DB, teamManager *team.Manager, settings *setting.Manager, businessHours *businesshours.Manager, template *tmpl.Manager, userManager *user.Manager, i18n *i18n.I18n, dispatcher *notifier.Dispatcher) *sla.Manager { var lo = initLogger("sla") m, err := sla.New(sla.Opts{ DB: db, Lo: lo, I18n: i18n, }, teamManager, settings, businessHours, template, userManager, dispatcher) if err != nil { log.Fatalf("error initializing SLA manager: %v", err) } return m } // initCSAT inits CSAT manager. func initCSAT(db *sqlx.DB, i18n *i18n.I18n) *csat.Manager { var lo = initLogger("csat") m, err := csat.New(csat.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing CSAT manager: %v", err) } return m } // initWS inits websocket hub. func initWS(user *user.Manager) *ws.Hub { return ws.NewHub(initLogger("ws"), user) } // getCustomStaticDir returns the custom static directory path from CLI flag or config. func getCustomStaticDir() string { dir := ko.String("static-dir") if dir == "" { dir = ko.String("app.static_dir") } if dir == "" { return "" } abs, err := filepath.Abs(dir) if err != nil { return dir } return abs } // initTemplates inits template manager. func initTemplate(db *sqlx.DB, fs stuffbin.FileSystem, consts *constants, i18n *i18n.I18n) *tmpl.Manager { var ( lo = initLogger("template") funcMap = getTmplFuncs(consts, i18n, fs) ) tpls, err := stuffbin.ParseTemplatesGlob(funcMap, fs, "/static/email-templates/*.html") if err != nil { log.Fatalf("error parsing e-mail templates: %v", err) } webTpls, err := parseWebTemplates(funcMap, fs) if err != nil { log.Fatalf("error parsing web templates: %v", err) } m, err := tmpl.New(lo, db, webTpls, tpls, funcMap, i18n) if err != nil { log.Fatalf("error initializing template manager: %v", err) } return m } // getTmplFuncs returns the template functions. func getTmplFuncs(consts *constants, i18n *i18n.I18n, fs stuffbin.FileSystem) template.FuncMap { lucideIcons := loadLucideIcons(fs) return template.FuncMap{ "LucideIcon": func(name string) template.HTML { return lucideIcons[name] }, "RootURL": func() string { return consts.AppBaseURL }, "FaviconURL": func() string { return consts.FaviconURL }, "AssetVer": func() string { return assetVersion }, "Date": func(layout string) string { if layout == "" { layout = time.ANSIC } return time.Now().Format(layout) }, "LogoURL": func() string { return consts.LogoURL }, "SiteName": func() string { return consts.SiteName }, "L": func() any { return i18n }, "map": func(pairs ...any) (map[string]any, error) { if len(pairs)%2 != 0 { return nil, fmt.Errorf("map: odd number of arguments") } out := make(map[string]any, len(pairs)/2) for i := 0; i < len(pairs); i += 2 { key, ok := pairs[i].(string) if !ok { return nil, fmt.Errorf("map: key %v is not a string", pairs[i]) } out[key] = pairs[i+1] } return out, nil }, } } // reloadSettings reloads the settings from the database into the Koanf instance. func reloadSettings(app *App) error { app.lo.Info("reloading settings") j, err := app.setting.GetAllJSON() if err != nil { app.lo.Error("error parsing settings from DB", "error", err) return err } var out map[string]any if err := json.Unmarshal(j, &out); err != nil { app.lo.Error("error unmarshalling settings from DB", "error", err) return err } app.Lock() err = ko.Load(confmap.Provider(out, "."), nil) app.Unlock() if err != nil { app.lo.Error("error loading settings into koanf", "error", err) return err } newConsts := initConstants() app.consts.Store(newConsts) return nil } // reloadTemplates reloads the templates from the filesystem. func reloadTemplates(app *App) error { app.lo.Info("reloading templates") funcMap := getTmplFuncs(app.consts.Load().(*constants), app.i18n, app.fs) tpls, err := stuffbin.ParseTemplatesGlob(funcMap, app.fs, "/static/email-templates/*.html") if err != nil { app.lo.Error("error parsing email templates", "error", err) return err } webTpls, err := parseWebTemplates(funcMap, app.fs) if err != nil { app.lo.Error("error parsing web templates", "error", err) return err } return app.tmpl.Reload(webTpls, tpls, funcMap) } // parseWebTemplates parses the top-level web templates and the per-template help center pages. func parseWebTemplates(funcMap template.FuncMap, fs stuffbin.FileSystem) (*template.Template, error) { var paths []string for _, pattern := range []string{ "/static/public/web-templates/*.html", "/static/public/web-templates/help/*/*.html", } { p, err := fs.Glob(pattern) if err != nil { return nil, err } paths = append(paths, p...) } return stuffbin.ParseTemplates(funcMap, fs, paths...) } // initTeam inits team manager. func initTeam(db *sqlx.DB, i18n *i18n.I18n) *team.Manager { var lo = initLogger("team-manager") mgr, err := team.New(team.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing team manager: %v", err) } return mgr } // initMedia inits media manager. func initMedia(db *sqlx.DB, i18n *i18n.I18n, settings *setting.Manager) *media.Manager { var ( store media.Store err error lo = initLogger("media") ) rootURL := func() string { u, err := settings.GetAppRootURL() if err != nil { // Fallback to config if settings fetch fails return ko.String("app.root_url") } return u } switch s := ko.MustString("upload.provider"); s { case "s3": store, err = s3.New(s3.Opt{ URL: ko.String("upload.s3.url"), PublicURL: ko.String("upload.s3.public_url"), AccessKey: ko.String("upload.s3.access_key"), SecretKey: ko.String("upload.s3.secret_key"), Region: ko.String("upload.s3.region"), Bucket: ko.String("upload.s3.bucket"), BucketPath: ko.String("upload.s3.bucket_path"), // All files are private by default. BucketType: "private", Expiry: ko.Duration("upload.s3.expiry"), }) if err != nil { log.Fatalf("error initializing s3 media store: %v", err) } case "fs": // Default expiry to 1h if not set. fsExpiry := ko.Duration("upload.fs.expiry") if fsExpiry == 0 { fsExpiry = 1 * time.Hour } store, err = fs.New(fs.Opts{ UploadURI: media.PublicURI, UploadPath: filepath.Clean(ko.String("upload.fs.upload_path")), RootURL: rootURL, SigningKey: ko.MustString("app.encryption_key"), Expiry: fsExpiry, }) if err != nil { log.Fatalf("error initializing fs media store: %v", err) } default: log.Fatalf("unknown media store: %s", s) } media, err := media.New(media.Opts{ Store: store, Lo: lo, DB: db, I18n: i18n, RootURL: rootURL, }) if err != nil { log.Fatalf("error initializing media: %v", err) } return media } // initInbox initializes the inbox manager without registering inboxes. func initInbox(db *sqlx.DB, i18n *i18n.I18n) *inbox.Manager { var lo = initLogger("inbox-manager") mgr, err := inbox.New(lo, db, i18n, ko.MustString("app.encryption_key")) if err != nil { log.Fatalf("error initializing inbox manager: %v", err) } return mgr } // initAutomationEngine initializes the automation engine. func initAutomationEngine(db *sqlx.DB, i18n *i18n.I18n) *automation.Engine { var lo = initLogger("automation_engine") engine, err := automation.New(automation.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing automation engine: %v", err) } return engine } // initAutoAssigner initializes the auto assigner. func initAutoAssigner(teamManager *team.Manager, userManager *user.Manager, conversationManager *conversation.Manager) *autoassigner.Engine { systemUser, err := userManager.GetSystemUser() if err != nil { log.Fatalf("error fetching system user: %v", err) } e, err := autoassigner.New(teamManager, conversationManager, systemUser, initLogger("autoassigner")) if err != nil { log.Fatalf("error initializing auto assigner: %v", err) } return e } // initNotifier initializes the notifier service with available providers. func initNotifier() *notifier.Service { smtpCfg := imodels.SMTPConfig{} if err := ko.UnmarshalWithConf("notification.email", &smtpCfg, koanf.UnmarshalConf{Tag: "json"}); err != nil { log.Fatalf("error unmarshalling email notification provider config: %v", err) } emailNotifier, err := emailnotifier.New([]imodels.SMTPConfig{smtpCfg}, emailnotifier.Opts{ Lo: initLogger("email-notifier"), FromEmail: ko.String("notification.email.email_address"), }) if err != nil { log.Fatalf("error initializing email notifier: %v", err) } notifierProviders := map[string]notifier.Notifier{ emailNotifier.Name(): emailNotifier, } return notifier.NewService(notifierProviders, ko.MustInt("notification.concurrency"), ko.MustInt("notification.queue_size"), initLogger("notifier")) } // initEmailInbox loads inbox config from DB and initializes the email inbox. func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore, mgr *inbox.Manager) (inbox.Inbox, error) { var config imodels.Config // Load JSON data into Koanf. if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil { return nil, fmt.Errorf("loading config: %w", err) } if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil { return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err) } if len(config.SMTP) == 0 { log.Printf("WARNING: Zero SMTP servers configured for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name) } if len(config.IMAP) == 0 { log.Printf("WARNING: Zero IMAP clients configured for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name) } config.From = inboxRecord.From config.FromNameTemplate = inboxRecord.FromNameTemplate if len(config.From) == 0 { log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name) } // Callback to persist refreshed tokens in DB. tokenRefreshCallback := func(inboxID int, updatedConfig imodels.Config) error { // Marshal updated config to JSON updatedConfigJSON, err := json.Marshal(updatedConfig) if err != nil { log.Printf("ERROR: Failed to marshal updated config during token refresh for inbox %d: %v", inboxID, err) return err } // Persist updated config to DB if err := mgr.UpdateConfig(inboxID, updatedConfigJSON); err != nil { log.Printf("ERROR: Failed to persist refreshed tokens during operation for inbox %d: %v", inboxID, err) return err } log.Printf("INFO: Successfully persisted refreshed tokens during operation for inbox: %d", inboxID) return nil } inbox, err := email.New(msgStore, usrStore, email.Opts{ ID: inboxRecord.ID, Name: inboxRecord.Name, Config: config, Lo: initLogger("email_inbox"), TokenRefreshCallback: tokenRefreshCallback, }) if err != nil { return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err) } log.Printf("`%s` inbox successfully initialized", inboxRecord.Name) return inbox, nil } // initLiveChatInbox initializes the live chat inbox. func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore, signAvatarURL func(*null.String)) (inbox.Inbox, error) { var config livechat.Config // Load JSON data into Koanf. if err := ko.Load(rawbytes.Provider([]byte(inboxRecord.Config)), kjson.Parser()); err != nil { return nil, fmt.Errorf("loading config: %w", err) } if err := ko.UnmarshalWithConf("", &config, koanf.UnmarshalConf{Tag: "json"}); err != nil { return nil, fmt.Errorf("unmarshalling `%s` %s config: %w", inboxRecord.Channel, inboxRecord.Name, err) } inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{ ID: inboxRecord.ID, Name: inboxRecord.Name, Config: config, Lo: initLogger("livechat_inbox"), SignAvatarURL: signAvatarURL, }) if err != nil { return nil, fmt.Errorf("initializing `%s` inbox: `%s` error : %w", inboxRecord.Channel, inboxRecord.Name, err) } log.Printf("`%s` inbox successfully initialized", inboxRecord.Name) return inbox, nil } // makeInboxInitializer creates an inbox initializer function. func makeInboxInitializer(mgr *inbox.Manager, signAvatarURL func(*null.String)) func(imodels.Inbox, inbox.MessageStore, inbox.UserStore) (inbox.Inbox, error) { return func(inboxR imodels.Inbox, msgStore inbox.MessageStore, usrStore inbox.UserStore) (inbox.Inbox, error) { switch inboxR.Channel { case inbox.ChannelEmail: return initEmailInbox(inboxR, msgStore, usrStore, mgr) case inbox.ChannelLiveChat: return initLiveChatInbox(inboxR, msgStore, usrStore, signAvatarURL) default: return nil, fmt.Errorf("unknown inbox channel: %s", inboxR.Channel) } } } // reloadInbox reloads a single inbox by ID using the signal-aware context. func reloadInbox(app *App, id int) error { app.lo.Info("reloading inbox", "id", id) return app.inbox.ReloadInbox(app.ctx, id, makeInboxInitializer(app.inbox, app.conversation.SignAvatarURL)) } // startInboxes registers the active inboxes and starts receiver for each. func startInboxes(ctx context.Context, mgr *inbox.Manager, msgStore inbox.MessageStore, usrStore inbox.UserStore, signAvatarURL func(*null.String)) { mgr.SetMessageStore(msgStore) mgr.SetUserStore(usrStore) if err := mgr.InitInboxes(makeInboxInitializer(mgr, signAvatarURL)); err != nil { log.Fatalf("error initializing inboxes: %v", err) } if err := mgr.Start(ctx); err != nil { log.Fatalf("error starting inboxes: %v", err) } } // initAuthz initializes authorization enforcer. func initAuthz(i18n *i18n.I18n) *authz.Enforcer { enforcer, err := authz.NewEnforcer(initLogger("authz"), i18n) if err != nil { log.Fatalf("error initializing authz: %v", err) } return enforcer } // initSSRFControl builds the shared outbound-request guard from config. func initSSRFControl() ssrf.Control { lo := initLogger("ssrf") return ssrf.NewControl(ko.Bool("ssrf.enabled"), ko.Strings("ssrf.allowed_cidrs"), lo) } // initAuth initializes the authentication manager. func initAuth(o *oidc.Manager, rd *redis.Client, i18n *i18n.I18n, dialControl ssrf.Control) *auth_.Auth { lo := initLogger("auth") providers, err := buildProviders(o) if err != nil { log.Fatalf("error initializing auth: %v", err) } secure := !ko.Bool("app.server.disable_secure_cookies") sessionLifetime := ko.Duration("app.server.session_lifetime") auth, err := auth_.New(auth_.Config{Providers: providers, SecureCookies: secure, SessionLifetime: sessionLifetime}, i18n, rd, lo, dialControl) if err != nil { log.Fatalf("error initializing auth: %v", err) } return auth } // reloadAuth reloads the auth providers. func reloadAuth(app *App) error { app.lo.Info("reloading auth manager") providers, err := buildProviders(app.oidc) if err != nil { app.lo.Error("error reloading auth", "error", err) return err } if err := app.auth.Reload(auth_.Config{Providers: providers}); err != nil { app.lo.Error("error reloading auth", "error", err) return err } return nil } // buildProviders creates a list of auth providers from the OIDC manager. func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) { oidcConfigs, err := o.GetAll() if err != nil { return nil, err } providers := make([]auth_.Provider, 0, len(oidcConfigs)) for _, config := range oidcConfigs { if !config.Enabled { continue } providers = append(providers, auth_.Provider{ ID: config.ID, Provider: config.Provider, ProviderURL: config.ProviderURL, RedirectURL: func() (string, error) { return o.RedirectURL(config.ID) }, ClientID: config.ClientID, ClientSecret: config.ClientSecret, }) } return providers, nil } // initOIDC initializes open id connect config manager. func initOIDC(db *sqlx.DB, settings *setting.Manager, i18n *i18n.I18n) *oidc.Manager { lo := initLogger("oidc") o, err := oidc.New(oidc.Opts{ DB: db, Lo: lo, I18n: i18n, EncryptionKey: ko.MustString("app.encryption_key"), }, settings) if err != nil { log.Fatalf("error initializing oidc: %v", err) } return o } // initI18n inits i18n. func initI18n(fs stuffbin.FileSystem) *i18n.I18n { lang := cmp.Or(ko.String("app.lang"), defLang) log.Printf("loading i18n language file: %s", lang) if _, err := fs.Read("/i18n/" + lang + ".json"); err != nil { log.Fatalf("error reading i18n language file `%s` : %v", lang, err) } i18n, err := loadI18nLang(lang, fs) if err != nil { log.Fatalf("error initializing i18n: %v", err) } return i18n } // initRedis inits redis DB. func initRedis() *redis.Client { // Load options from redis URL if set. redisURL := ko.String("redis.url") if redisURL != "" { options, err := redis.ParseURL(redisURL) if err != nil { log.Fatalf("error parsing redis url: %v", err) } return redis.NewClient(options) } // Load from individual config options. return redis.NewClient(&redis.Options{ Addr: ko.MustString("redis.address"), Username: ko.String("redis.user"), Password: ko.String("redis.password"), DB: ko.Int("redis.db"), }) } func initFastCache(rdb *redis.Client) *fastcache.FastCache { return fastcache.New(goredis.New(goredis.Config{Prefix: fastCachePrefix}, rdb)) } // initRedis inits postgres DB. func initDB() *sqlx.DB { dsn := fmt.Sprintf( "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s %s", ko.MustString("db.host"), ko.MustInt("db.port"), ko.MustString("db.user"), ko.MustString("db.password"), ko.MustString("db.database"), ko.String("db.ssl_mode"), ko.String("db.params"), ) db, err := sqlx.Connect("postgres", dsn) if err != nil { log.Fatalf("error connecting to DB: %v", err) } db.SetMaxOpenConns(ko.MustInt("db.max_open")) db.SetMaxIdleConns(ko.MustInt("db.max_idle")) db.SetConnMaxLifetime(ko.MustDuration("db.max_lifetime")) return db } // initRedis inits role manager. func initRole(db *sqlx.DB, i18n *i18n.I18n) *role.Manager { var lo = initLogger("role_manager") r, err := role.New(role.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing role manager: %v", err) } return r } // initStatus inits conversation status manager. func initStatus(db *sqlx.DB, i18n *i18n.I18n) *status.Manager { manager, err := status.New(status.Opts{ DB: db, Lo: initLogger("status-manager"), I18n: i18n, }) if err != nil { log.Fatalf("error initializing status manager: %v", err) } return manager } // initPriority inits conversation priority manager. func initPriority(db *sqlx.DB, i18n *i18n.I18n) *priority.Manager { manager, err := priority.New(priority.Opts{ DB: db, Lo: initLogger("priority-manager"), I18n: i18n, }) if err != nil { log.Fatalf("error initializing priority manager: %v", err) } return manager } // initAI inits AI manager. func initAI(ctx context.Context, db *sqlx.DB, i18n *i18n.I18n, dialControl ssrf.Control) *ai.Manager { lo := initLogger("ai") m, err := ai.New(ai.Opts{ Ctx: ctx, DB: db, Lo: lo, I18n: i18n, EncryptionKey: ko.MustString("app.encryption_key"), DialControl: dialControl, }) if err != nil { log.Fatalf("error initializing AI manager: %v", err) } return m } // initHelpCenter inits the help center manager. func initHelpCenter(db *sqlx.DB, i18n *i18n.I18n, indexer helpcenter.ArticleIndexer) *helpcenter.Manager { m, err := helpcenter.New(helpcenter.Opts{ DB: db, Lo: initLogger("helpcenter"), I18n: i18n, Indexer: indexer, }) if err != nil { log.Fatalf("error initializing help center manager: %v", err) } return m } // initAIAgent inits the autonomous AI agent manager. func initAIAgent(db *sqlx.DB, i18n *i18n.I18n, aiManager *ai.Manager, convo *conversation.Manager, mediaManager *media.Manager, settingManager *setting.Manager, userManager *user.Manager, notifierService *notifier.Service, rdb *redis.Client) *aiagent.Manager { m, err := aiagent.New(aiagent.Opts{ DB: db, Lo: initLogger("ai_agent"), I18n: i18n, QueueSize: cmp.Or(ko.Int("ai_agent.queue_size"), 1000), MaxSteps: min(max(cmp.Or(ko.Int("ai_agent.max_steps"), 6), 1), 20), MaxHistoryMessages: min(max(cmp.Or(ko.Int("ai_agent.max_history_messages"), 30), 5), 100), }, aiManager, convo, mediaManager, settingManager, userManager, notifierService, rdb) if err != nil { log.Fatalf("error initializing AI agent manager: %v", err) } return m } // initSearch inits search manager. func initSearch(db *sqlx.DB, i18n *i18n.I18n) *search.Manager { lo := initLogger("search") m, err := search.New(search.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing search manager: %v", err) } return m } // initCustomAttribute inits custom attribute manager. func initCustomAttribute(db *sqlx.DB, i18n *i18n.I18n) *customAttribute.Manager { lo := initLogger("custom-attribute") m, err := customAttribute.New(customAttribute.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing custom attribute manager: %v", err) } return m } // initActivityLog inits activity log manager. func initActivityLog(db *sqlx.DB, i18n *i18n.I18n) *activitylog.Manager { lo := initLogger("activity-log") m, err := activitylog.New(activitylog.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing activity log manager: %v", err) } return m } // initReport inits report manager. func initReport(db *sqlx.DB, i18n *i18n.I18n) *report.Manager { lo := initLogger("report") m, err := report.New(report.Opts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing report manager: %v", err) } return m } // initContextLink inits context link manager. func initContextLink(db *sqlx.DB, i18n *i18n.I18n) *contextlink.Manager { var lo = initLogger("context-link") m, err := contextlink.New(contextlink.Opts{ DB: db, Lo: lo, I18n: i18n, EncryptionKey: ko.MustString("app.encryption_key"), }) if err != nil { log.Fatalf("error initializing context link manager: %v", err) } return m } // initWebhook inits webhook manager. func initWebhook(db *sqlx.DB, i18n *i18n.I18n, dialControl ssrf.Control) *webhook.Manager { var lo = initLogger("webhook") m, err := webhook.New(webhook.Opts{ DB: db, Lo: lo, I18n: i18n, Workers: ko.MustInt("webhook.workers"), QueueSize: ko.MustInt("webhook.queue_size"), Timeout: ko.MustDuration("webhook.timeout"), EncryptionKey: ko.MustString("app.encryption_key"), DialControl: dialControl, }) if err != nil { log.Fatalf("error initializing webhook manager: %v", err) } return m } // initUserNotification inits user notification manager. func initUserNotification(db *sqlx.DB, i18n *i18n.I18n) *notifier.UserNotificationManager { var lo = initLogger("user-notification") m, err := notifier.NewUserNotificationManager(notifier.UserNotificationOpts{ DB: db, Lo: lo, I18n: i18n, }) if err != nil { log.Fatalf("error initializing user notification manager: %v", err) } return m } // initImporter inits the importer manager. func initImporter(i18n *i18n.I18n) *importer.Importer { return importer.New(importer.Opts{ Lo: initLogger("importer"), I18n: i18n, }) } // initNotifDispatcher initializes the notification dispatcher. func initNotifDispatcher(userNotification *notifier.UserNotificationManager, outbound *notifier.Service, wsHub *ws.Hub, emailEnabled bool) *notifier.Dispatcher { return notifier.NewDispatcher(notifier.DispatcherOpts{ InApp: userNotification, Outbound: outbound, WSHub: wsHub, EmailEnabled: emailEnabled, Lo: initLogger("notification-dispatcher"), }) } // initLogger initializes a logf logger. func initLogger(src string) *logf.Logger { lvl, env := ko.MustString("app.log_level"), ko.MustString("app.env") lo := logf.New(logf.Opts{ Level: getLogLevel(lvl), EnableColor: getColor(env), EnableCaller: true, CallerSkipFrameCount: 3, DefaultFields: []any{"sc", src}, }) return &lo } func getColor(env string) bool { color := false if env == "dev" { color = true } return color } func getLogLevel(lvl string) logf.Level { switch lvl { case "info": return logf.InfoLevel case "debug": return logf.DebugLevel case "warn": return logf.WarnLevel case "error": return logf.ErrorLevel case "fatal": return logf.FatalLevel default: return logf.InfoLevel } } // initRateLimit initializes the rate limiter with default rules. // Defaults are used unless overridden in config.toml under [rate_limit.]. func initRateLimit(redisClient *redis.Client) *ratelimit.Limiter { limiter := ratelimit.New(redisClient) defaults := []struct { Name string RPM int }{ {"widget", 100}, {"auth", 30}, {"public", 100}, {"media", 300}, } for _, d := range defaults { enabled := true rpm := d.RPM cfgKey := "rate_limit." + d.Name if ko.Exists(cfgKey) { var cfg struct { Enabled bool `toml:"enabled"` RequestsPerMinute int `toml:"requests_per_minute"` } ko.UnmarshalWithConf(cfgKey, &cfg, koanf.UnmarshalConf{Tag: "toml"}) enabled = cfg.Enabled if cfg.RequestsPerMinute > 0 { rpm = cfg.RequestsPerMinute } } if !enabled { log.Printf("WARNING: rate limit rule '%s' is disabled", d.Name) } limiter.AddRule(ratelimit.Rule{ Name: d.Name, Enabled: enabled, RequestsPerMinute: rpm, }) } return limiter }