package settings import ( "bytes" "fmt" "io" "log" "os" "os/exec" "path/filepath" "runtime" "slices" "strconv" "strings" "time" "github.com/go-playground/validator/v10" "github.com/goccy/go-yaml" "github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils" "github.com/gtsteffaniak/filebrowser/backend/common/utils" "github.com/gtsteffaniak/filebrowser/backend/common/version" "github.com/gtsteffaniak/filebrowser/backend/database/users" "github.com/gtsteffaniak/go-logger/logger" ) var Config Settings const ( generatorPath = "/relative/or/absolute/path" ) func Initialize(configFile string) { err := loadConfigWithDefaults(configFile, false) if err != nil { logger.Error("unable to load config, waiting 5 seconds before exiting...") time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting logger.Fatal(err.Error()) } setupLogging() // setup logging first to ensure we log any errors setupEnv() // Migrate deprecated UserDefaults fields to new organized structure migrateUserDefaults() err = ValidateConfig(Config) if err != nil { errmsg := "The provided config file failed validation. " errmsg += "If you are seeing this on a config that worked before, " errmsg += "then check the latest releases for breaking changes. " errmsg += "visit https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example for more information." logger.Error(errmsg) time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting logger.Fatal(err.Error()) } setupFs() setupServer() setupAuth(false) setupSources(false) InitializeUserResolvers() // Initialize user package resolvers after sources are set up setupUrls() setupFrontend(false) setupMedia(false) } func setupServer() { if Config.Server.ListenAddress == "" { Config.Server.ListenAddress = "0.0.0.0" } // Check environment variable first (overrides config file) if os.Getenv("FILEBROWSER_SQL_WAL") == "true" { Config.Server.IndexSqlConfig.WalMode = true } // WalMode is false by default (OFF journaling) } func setupEnv() { Env.IsPlaywright = os.Getenv("FILEBROWSER_PLAYWRIGHT_TEST") == "true" if Env.IsPlaywright { logger.Warning("Running in playwright test mode. This is not recommended for production.") } Env.IsDevMode = os.Getenv("FILEBROWSER_DEVMODE") == "true" if Env.IsDevMode { logger.Warning("Running in dev mode. This is not recommended for production.") } } func setupFs() { // Convert permission values (like 644, 755) to octal interpretation filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32) if err != nil { Config.Server.Filesystem.CreateFilePermission = "644" filePermOctal, _ = strconv.ParseUint("644", 8, 32) } dirPermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateDirectoryPermission, 8, 32) if err != nil { Config.Server.Filesystem.CreateDirectoryPermission = "755" dirPermOctal, _ = strconv.ParseUint("755", 8, 32) } fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal)) // Perform mandatory cache directory speed test testCacheDirSpeed() if err := PrepareDownloadSpoolDir(); err != nil { logger.Fatalf("cacheDir failed to prepare download spool: %v", err) } logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir) } // testCacheDirSpeed performs a mandatory speed test on the cache directory by writing, // reading, and deleting a 10MB test file. Reports write and read performance in MB/s. func testCacheDirSpeed() { msgPrfx := "cacheDir" failSuffix := "Please review documentation to ensure a valid cache directory is configured https://filebrowserquantum.com/en/docs/configuration/server/#cachedir" const testFileSize = 10 * 1024 * 1024 // 10MB // Ensure cache directory exists err := os.MkdirAll(Config.Server.CacheDir, fileutils.PermDir) if err != nil { logger.Fatalf("%s failed to create cache directory: %v\n%s", msgPrfx, err, failSuffix) } testFileName := filepath.Join(Config.Server.CacheDir, "speed_test.tmp") // Create test data (10MB of zeros) testData := make([]byte, testFileSize) // Test write performance writeStart := time.Now() file, err := os.Create(testFileName) if err != nil { logger.Fatalf("%s failed to create test file: %v\n%s", msgPrfx, err, failSuffix) } written, err := file.Write(testData) if err != nil { file.Close() os.Remove(testFileName) logger.Fatalf("%s failed to write test file: %v\n%s", msgPrfx, err, failSuffix) } err = file.Sync() // Ensure data is written to disk if err != nil { file.Close() os.Remove(testFileName) logger.Fatalf("%s failed to sync test file to disk: %v\n%s", msgPrfx, err, failSuffix) } err = file.Close() if err != nil { os.Remove(testFileName) logger.Fatalf("%s failed to close test file: %v\n%s", msgPrfx, err, failSuffix) } writeDuration := time.Since(writeStart) writeSpeedMBs := float64(written) / (1024 * 1024) / writeDuration.Seconds() // Test read performance readStart := time.Now() file, err = os.Open(testFileName) if err != nil { os.Remove(testFileName) logger.Fatalf("%s failed to open test file for reading: %v\n%s", msgPrfx, err, failSuffix) } readData := make([]byte, testFileSize) readBytes, err := io.ReadFull(file, readData) if err != nil { file.Close() os.Remove(testFileName) logger.Fatalf("%s failed to read test file: %v\n%s", msgPrfx, err, failSuffix) } err = file.Close() if err != nil { os.Remove(testFileName) logger.Fatalf("%s failed to close test file after reading: %v\n%s", msgPrfx, err, failSuffix) } readDuration := time.Since(readStart) readSpeedMBs := float64(readBytes) / (1024 * 1024) / readDuration.Seconds() // Verify data integrity if readBytes != written { os.Remove(testFileName) logger.Fatalf("%s data integrity check failed: wrote %d bytes but read %d bytes\n%s", msgPrfx, written, readBytes, failSuffix) } // Clean up test file err = os.Remove(testFileName) if err != nil { logger.Fatalf("%s failed to remove test file: %v\n%s", msgPrfx, err, failSuffix) } // Log performance results writeDurationMs := writeDuration.Seconds() * 1000 readDurationMs := readDuration.Seconds() * 1000 const highLatencyThresholdMs = 1000.0 // 1 second for 10MB file indicates high latency if writeSpeedMBs < 50 { slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option." logger.Warningf("%s slow write speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, writeSpeedMBs, writeDurationMs, failSuffix, slowSuffix) } else if writeDurationMs > highLatencyThresholdMs { slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option." logger.Warningf("%s high write latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, writeDurationMs, writeSpeedMBs, failSuffix, slowSuffix) } else { logger.Debugf("%s write speed: %.2f MB/s (%.2f ms)", msgPrfx, writeSpeedMBs, writeDurationMs) } if readSpeedMBs < 50 { slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option." logger.Warningf("%s slow read speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, readSpeedMBs, readDurationMs, failSuffix, slowSuffix) } else if readDurationMs > highLatencyThresholdMs { slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option." logger.Warningf("%s high read latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, readDurationMs, readSpeedMBs, failSuffix, slowSuffix) } else { logger.Debugf("%s read speed : %.2f MB/s (%.2f ms)", msgPrfx, readSpeedMBs, readDurationMs) } // check cache directory disk free space freeSpace, err := fileutils.GetFreeSpace(Config.Server.CacheDir) if err != nil { logger.Fatalf("%s failed to get free space for cache directory: %v\n%s", msgPrfx, err, failSuffix) } freeSpaceGB := float64(freeSpace) / (1024 * 1024 * 1024) logger.Debugf("%s cache directory has %.2f GB of free space", msgPrfx, freeSpaceGB) const minRecommendedGB = 20.0 if freeSpaceGB < minRecommendedGB { logger.Warningf("%s only has %.2f GB of free space, this is less than the %.0f GB minimum recommended free space.\n%s", msgPrfx, freeSpaceGB, minRecommendedGB, failSuffix) } } func setupFrontend(generate bool) { // Load login icon configuration at startup loadLoginIcon() if Config.Server.MinSearchLength == 0 { Config.Server.MinSearchLength = 3 } if !Config.Frontend.DisableDefaultLinks { Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{ Text: fmt.Sprintf("(%v)", version.Version), Title: version.CommitSHA, Url: "https://github.com/gtsteffaniak/filebrowser/releases/", }) Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{ Text: "Help", Url: "help prompt", }) } if Config.Frontend.Description == "" { Config.Frontend.Description = "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" } Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5") Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24") var err error if Config.Frontend.Styling.CustomCSS != "" { Config.Frontend.Styling.CustomCSSRaw, err = readCustomCSS(Config.Frontend.Styling.CustomCSS) if err != nil { logger.Warning(err.Error()) } } Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{} if Config.Frontend.Styling.CustomThemes == nil { Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{} } for name, theme := range Config.Frontend.Styling.CustomThemes { addCustomTheme(name, theme.Description, theme.CSS) } noThemes := len(Config.Frontend.Styling.CustomThemes) == 0 if noThemes { addCustomTheme("default", "The default theme", "") // check if file exists if _, err := os.Stat("reduce-rounded-corners.css"); err == nil { addCustomTheme("alternative", "Reduce rounded corners", "reduce-rounded-corners.css") if generate { Config.Frontend.Styling.CustomThemes["alternative"] = CustomTheme{ Description: "Reduce rounded corners", CSS: "reduce-rounded-corners.css", } } } } _, ok := Config.Frontend.Styling.CustomThemes["default"] if !ok { addCustomTheme("default", "The default theme", "") } // Load custom favicon if configured loadCustomFavicon() } func setupMedia(generate bool) { // Save user's explicit config before applying defaults userImageConfig := make(map[ImagePreviewType]*bool) for k, v := range Config.Integrations.Media.Convert.ImagePreview { userImageConfig[k] = v } // Re-initialize with defaults (gets wiped out during YAML unmarshal) Config.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool) Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled Config.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled // Apply user overrides (only for keys explicitly set in YAML) for k, v := range userImageConfig { Config.Integrations.Media.Convert.ImagePreview[k] = v } // VideoPreview: Merge user config with defaults // Save user's explicit config from YAML userVideoConfig := make(map[VideoPreviewType]*bool) for k, v := range Config.Integrations.Media.Convert.VideoPreview { userVideoConfig[k] = v } // Re-initialize with defaults (all enabled) Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool) for _, t := range AllVideoPreviewTypes { Config.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true) } // Apply user overrides (only for keys explicitly set in YAML) for k, v := range userVideoConfig { Config.Integrations.Media.Convert.VideoPreview[k] = v } // Resolve exiftool path once at startup: validate user path or discover via PATH if Config.Integrations.Media.ExiftoolPath != "" && !generate { if err := exec.Command(Config.Integrations.Media.ExiftoolPath, "-ver").Run(); err != nil { logger.Warningf("exiftool path is invalid or not executable: %q (%v); disabling exiftool", Config.Integrations.Media.ExiftoolPath, err) Config.Integrations.Media.ExiftoolPath = "" } } if Config.Integrations.Media.ExiftoolPath == "" && !generate { if path, err := exec.LookPath("exiftool"); err == nil && path != "" { Config.Integrations.Media.ExiftoolPath = path } } } func setupSources(generate bool) { if len(Config.Server.Sources) == 0 { logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.") } else { for k, source := range Config.Server.Sources { if source.Config.Disabled { continue } realPath, err := filepath.Abs(source.Path) if err != nil { logger.Fatalf("error getting real path for source %v: %v", source.Path, err) } exists := utils.CheckPathExists(realPath) if !exists { logger.Warningf("source path %v is currently not available", realPath) } name := filepath.Base(realPath) if name == "\\" { name = strings.Split(realPath, ":")[0] } source.Path = realPath // use absolute path if source.Name == "" { _, ok := Config.Server.SourceMap[source.Path] if ok { source.Name = name + fmt.Sprintf("-%v", k) } else { source.Name = name } } if generate { source.Path = generatorPath // use placeholder path source.Name = "Source Name" } modifyExcludeInclude(source) setConditionals(source) if source.Config.DefaultUserScope == "" { source.Config.DefaultUserScope = "/" } Config.Server.SourceMap[source.Path] = source Config.Server.NameToSource[source.Name] = source } } // clean up the in memory source list to be accurate and unique sourceList := []*Source{} defaultScopes := []users.SourceScope{} allSourceNames := []string{} if len(Config.Server.Sources) == 1 { Config.Server.Sources[0].Config.DefaultEnabled = true } for _, sourcePathOnly := range Config.Server.Sources { absPath := sourcePathOnly.Path if generate { // When generating, skip path validation and use the already-set path absPath = sourcePathOnly.Path } source, ok := Config.Server.SourceMap[absPath] if ok && !slices.Contains(allSourceNames, source.Name) { sourceList = append(sourceList, source) if source.Config.DefaultEnabled { defaultScopes = append(defaultScopes, users.SourceScope{ Name: source.Path, Scope: source.Config.DefaultUserScope, }) } allSourceNames = append(allSourceNames, source.Name) } else { logger.Warningf("skipping source: %v", sourcePathOnly.Path) } } Config.UserDefaults.DefaultScopes = defaultScopes Config.Server.Sources = sourceList } func setupUrls() { baseurl := strings.Trim(Config.Server.BaseURL, "/") if baseurl == "" { Config.Server.BaseURL = "/" } else { Config.Server.BaseURL = "/" + baseurl + "/" } if Config.Server.BaseURL != "/" { if Config.Server.ExternalUrl != "" { Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, "/") + "/" Config.Server.ExternalUrl = strings.TrimSuffix(Config.Server.ExternalUrl, Config.Server.BaseURL) } if Config.Server.InternalUrl != "" { Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, "/") + "/" Config.Server.InternalUrl = strings.TrimSuffix(Config.Server.InternalUrl, Config.Server.BaseURL) } } Config.Integrations.OnlyOffice.Url = strings.Trim(Config.Integrations.OnlyOffice.Url, "/") Config.Integrations.OnlyOffice.InternalUrl = strings.Trim(Config.Integrations.OnlyOffice.InternalUrl, "/") } func setupAuth(generate bool) { if generate { Config.Auth.AdminPassword = "admin" } if Config.Auth.Methods.PasswordAuth.Enabled { Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password") } if Config.Auth.Methods.ProxyAuth.Enabled { Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy") } if Config.Auth.Methods.NoAuth { logger.Warning("Configured with no authentication, this is not recommended.") Config.Auth.AuthMethods = []string{"disabled"} } if Config.Auth.Methods.OidcAuth.Enabled || generate { Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc") err := validateOidcAuth() if err != nil && !generate { logger.Fatalf("Error validating OIDC auth: %v", err) } logger.Info("OIDC Auth configured successfully") } if Config.Auth.Methods.LdapAuth.Enabled || generate { Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "ldap") err := ValidateLdapAuth() if err != nil && !generate { logger.Fatalf("Error validating LDAP auth: %v", err) } logger.Info("LDAP Auth configured successfully") } if Config.Auth.Methods.JwtAuth.Enabled || generate { Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "jwt") err := ValidateJwtAuth() if err != nil && !generate { logger.Fatalf("Error validating JWT auth: %v", err) } logger.Info("JWT Auth configured successfully") } if Config.Auth.Methods.PasskeyAuth.Enabled || generate { Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "passkey") logger.Info("Passkey Auth configured successfully") } // use password auth as default if no auth methods are set if len(Config.Auth.AuthMethods) == 0 { Config.Auth.Methods.PasswordAuth.Enabled = true Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "password") } Config.UserDefaults.Account.LoginMethod = Config.Auth.AuthMethods[0] } func setupLogging() { if len(Config.Server.Logging) == 0 { Config.Server.Logging = []LogConfig{ { Output: "stdout", }, } } for i := range Config.Server.Logging { cfg := &Config.Server.Logging[i] // Enable debug logging automatically in dev mode levels := cfg.Levels if os.Getenv("FILEBROWSER_DEVMODE") == "true" { levels = "info|warning|error|debug" } pattern := cfg.ApiFilter if pattern == "" { pattern = "^/health|^/favicon.ico|^/static|^/public/static" } jsonCfg := logger.JsonConfig{ Levels: levels, ApiLevels: cfg.ApiLevels, Output: cfg.Output, Utc: cfg.Utc, NoColors: cfg.NoColors, Json: cfg.Json, Structured: false, ApiPathExclude: pattern, } err := logger.EnableCompatibilityMode(jsonCfg) if err != nil { log.Println("[ERROR] Failed to set up logger:", err) } } } // migrateUserDefaults migrates deprecated UserDefaults fields to the new organized structure. // This function maintains backwards compatibility by checking if new fields are unset and copying // values from old deprecated fields if found. Logs warnings when old fields are migrated. func migrateUserDefaults() { ud := &Config.UserDefaults hasOldFields := false // Helper function to check if a bool pointer is unset (nil) isUnsetBoolPtr := func(v *bool) bool { return v == nil } // Helper function to check if a string is unset (empty) isUnsetString := func(v string) bool { return v == "" } // Helper function to check if an int is unset (zero) isUnsetInt := func(v int) bool { return v == 0 } // Migrate Preview-related deprecated fields if !ud.Sidebar.DisableHideOnPreview && ud.Preview.DisableHideSidebar { ud.Sidebar.DisableHideOnPreview = ud.Preview.DisableHideSidebar hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'preview.disableHideSidebar' to 'sidebar.disableHideOnPreview'") } if !ud.FileViewer.DefaultMediaPlayer && ud.Preview.DefaultMediaPlayer { ud.FileViewer.DefaultMediaPlayer = ud.Preview.DefaultMediaPlayer hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'preview.defaultMediaPlayer' to 'fileViewer.defaultMediaPlayer'") } if isUnsetBoolPtr(ud.FileViewer.AutoplayMedia) && ud.Preview.AutoplayMedia { autoplay := ud.Preview.AutoplayMedia ud.FileViewer.AutoplayMedia = &autoplay hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'preview.autoplayMedia' to 'fileViewer.autoplayMedia'") } if isUnsetString(ud.Preview.DisablePreviewExt) && !isUnsetString(ud.DisablePreviewExt) { ud.Preview.DisablePreviewExt = ud.DisablePreviewExt hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'disablePreviewExt' to 'preview.disablePreviewExt'") } // Migrate Sidebar fields if !ud.Sidebar.DisableQuickToggles && ud.DisableQuickToggles { ud.Sidebar.DisableQuickToggles = ud.DisableQuickToggles hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'disableQuickToggles' to 'sidebar.disableQuickToggles'") } if !ud.Sidebar.HideFileActions && ud.HideSidebarFileActions { ud.Sidebar.HideFileActions = ud.HideSidebarFileActions hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'hideSidebarFileActions' to 'sidebar.hideFileActions'") } if !ud.Sidebar.Sticky && ud.StickySidebar { ud.Sidebar.Sticky = ud.StickySidebar hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'stickySidebar' to 'sidebar.sticky'") } if isUnsetString(ud.Listing.ViewMode) && !isUnsetString(ud.ViewMode) { ud.Listing.ViewMode = ud.ViewMode hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'viewMode' to 'sidebar.viewMode'") } if isUnsetInt(ud.Listing.GallerySize) && !isUnsetInt(ud.GallerySize) { ud.Listing.GallerySize = ud.GallerySize hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'gallerySize' to 'sidebar.gallerySize'") } if !ud.Sidebar.HideFiles && ud.HideFilesInTree { ud.Sidebar.HideFiles = ud.HideFilesInTree hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'hideFilesInTree' to 'sidebar.hideFiles'") } if isUnsetBoolPtr(ud.Sidebar.ShowTools) && ud.ShowToolsInSidebar != nil { ud.Sidebar.ShowTools = ud.ShowToolsInSidebar hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'showToolsInSidebar' to 'sidebar.showTools'") } // Migrate Listing fields if !ud.Listing.DeleteWithoutConfirming && ud.DeleteWithoutConfirming { ud.Listing.DeleteWithoutConfirming = ud.DeleteWithoutConfirming hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'deleteWithoutConfirming' to 'listing.deleteWithoutConfirming'") } if !ud.Listing.DateFormat && ud.DateFormat { ud.Listing.DateFormat = ud.DateFormat hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'dateFormat' to 'listing.dateFormat'") } if !ud.Listing.ShowHidden && ud.ShowHidden { ud.Listing.ShowHidden = ud.ShowHidden hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'showHidden' to 'listing.showHidden'") } if !ud.Listing.QuickDownload && ud.QuickDownload { ud.Listing.QuickDownload = ud.QuickDownload hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'quickDownload' to 'listing.quickDownload'") } if !ud.Listing.ShowSelectMultiple && ud.ShowSelectMultiple { ud.Listing.ShowSelectMultiple = ud.ShowSelectMultiple hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'showSelectMultiple' to 'listing.showSelectMultiple'") } if !ud.Listing.SingleClick && ud.SingleClick { ud.Listing.SingleClick = ud.SingleClick hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'singleClick' to 'listing.singleClick'") } if isUnsetString(ud.Listing.HideFileExt) && !isUnsetString(ud.HideFileExt) { ud.Listing.HideFileExt = ud.HideFileExt hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'hideFileExt' to 'listing.hideFileExt'") } if !ud.Listing.ShowCopyPath && ud.ShowCopyPath { ud.Listing.ShowCopyPath = ud.ShowCopyPath hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'showCopyPath' to 'listing.showCopyPath'") } if !ud.Listing.DeleteAfterArchive && ud.DeleteAfterArchive { ud.Listing.DeleteAfterArchive = ud.DeleteAfterArchive hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'deleteAfterArchive' to 'listing.deleteAfterArchive'") } // Migrate FileViewer fields if !ud.FileViewer.EditorQuickSave && ud.EditorQuickSave { ud.FileViewer.EditorQuickSave = ud.EditorQuickSave hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'editorQuickSave' to 'fileViewer.editorQuickSave'") } if isUnsetString(ud.FileViewer.DisableViewingExt) && !isUnsetString(ud.DisableViewingExt) { ud.FileViewer.DisableViewingExt = ud.DisableViewingExt hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'disableViewingExt' to 'fileViewer.disableViewingExt'") } if isUnsetString(ud.FileViewer.DisableOnlyOfficeExt) && !isUnsetString(ud.DisableOnlyOfficeExt) { ud.FileViewer.DisableOnlyOfficeExt = ud.DisableOnlyOfficeExt hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'disableOnlyOfficeExt' to 'fileViewer.disableOnlyOfficeExt'") } if !ud.FileViewer.PreferEditorForMarkdown && ud.PreferEditorForMarkdown { ud.FileViewer.PreferEditorForMarkdown = ud.PreferEditorForMarkdown hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'preferEditorForMarkdown' to 'fileViewer.preferEditorForMarkdown'") } if !ud.FileViewer.DebugOffice && ud.DebugOffice { ud.FileViewer.DebugOffice = ud.DebugOffice hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'debugOffice' to 'fileViewer.debugOffice'") } // Migrate Search fields if !ud.Search.DisableOptions && ud.DisableSearchOptions { ud.Search.DisableOptions = ud.DisableSearchOptions hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'disableSearchOptions' to 'search.disableOptions'") } // Migrate UI fields if isUnsetBoolPtr(ud.UI.DarkMode) && ud.DarkMode != nil { ud.UI.DarkMode = ud.DarkMode hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'darkMode' to 'ui.darkMode'") } if isUnsetString(ud.UI.ThemeColor) && !isUnsetString(ud.ThemeColor) { ud.UI.ThemeColor = ud.ThemeColor hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'themeColor' to 'ui.themeColor'") } if isUnsetString(ud.UI.CustomTheme) && !isUnsetString(ud.CustomTheme) { ud.UI.CustomTheme = ud.CustomTheme hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'customTheme' to 'ui.customTheme'") } if isUnsetString(ud.UI.Locale) && !isUnsetString(ud.Locale) { ud.UI.Locale = ud.Locale hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'locale' to 'ui.locale'") } // Migrate Account fields if !ud.Account.LockPassword && ud.LockPassword { ud.Account.LockPassword = ud.LockPassword hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'lockPassword' to 'account.lockPassword'") } if !ud.Account.DisableSettings && ud.DisableSettings { ud.Account.DisableSettings = ud.DisableSettings hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'disableSettings' to 'account.disableSettings'") } if isUnsetString(ud.Account.LoginMethod) && !isUnsetString(ud.LoginMethod) { ud.Account.LoginMethod = ud.LoginMethod hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'loginMethod' to 'account.loginMethod'") } if !ud.Account.DisableUpdateNotifications && ud.DisableUpdateNotifications { ud.Account.DisableUpdateNotifications = ud.DisableUpdateNotifications hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'disableUpdateNotifications' to 'account.disableUpdateNotifications'") } // Migrate Account Permissions fields if !ud.Account.Permissions.Api && ud.Permissions.Api { ud.Account.Permissions.Api = ud.Permissions.Api hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.api' to 'account.permissions.api'") } if !ud.Account.Permissions.Admin && ud.Permissions.Admin { ud.Account.Permissions.Admin = ud.Permissions.Admin hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.admin' to 'account.permissions.admin'") } if !ud.Account.Permissions.Modify && ud.Permissions.Modify { ud.Account.Permissions.Modify = ud.Permissions.Modify hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.modify' to 'account.permissions.modify'") } if !ud.Account.Permissions.Share && ud.Permissions.Share { ud.Account.Permissions.Share = ud.Permissions.Share hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.share' to 'account.permissions.share'") } if !ud.Account.Permissions.Realtime && ud.Permissions.Realtime { ud.Account.Permissions.Realtime = ud.Permissions.Realtime hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.realtime' to 'account.permissions.realtime'") } if !ud.Account.Permissions.Delete && ud.Permissions.Delete { ud.Account.Permissions.Delete = ud.Permissions.Delete hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.delete' to 'account.permissions.delete'") } if !ud.Account.Permissions.Create && ud.Permissions.Create { ud.Account.Permissions.Create = ud.Permissions.Create hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.create' to 'account.permissions.create'") } if isUnsetBoolPtr(ud.Account.Permissions.Download) && ud.Permissions.Download != nil { ud.Account.Permissions.Download = ud.Permissions.Download hasOldFields = true logger.Warning("userDefaults: migrating deprecated field 'permissions.download' to 'account.permissions.download'") } if hasOldFields { logger.Warning("userDefaults: Please update your configuration to use the new organized structure. See documentation for details.") } } func loadConfigWithDefaults(configFile string, generate bool) error { Config = SetDefaults(generate) // Check if config file exists if _, err := os.Stat(configFile); err != nil { if configFile != "" { logger.Errorf("could not open config file '%v', using default settings.", configFile) } Config.Server.Sources = []*Source{ { Path: ".", }, } loadEnvConfig() return nil } // Try multi-file config first (combine all YAML files in the directory) combinedYAML, err := combineYAMLFiles(configFile) if err != nil { return fmt.Errorf("failed to combine YAML files: %v", err) } // First pass: Unmarshal into a generic map to resolve all anchors and aliases // This allows YAML anchors defined in auxiliary files to be properly merged var rawConfig map[string]interface{} err = yaml.Unmarshal(combinedYAML, &rawConfig) if err != nil { return fmt.Errorf("error parsing YAML data: %v", err) } // Filter to only keep valid top-level Settings struct fields // This removes anchor definitions that are just templates (e.g., "test_server: &test_server") validFields := map[string]bool{ "server": true, "auth": true, "integrations": true, "frontend": true, "userDefaults": true, } filteredConfig := make(map[string]interface{}) for key, value := range rawConfig { if validFields[key] { filteredConfig[key] = value } } // Marshal the filtered config back to YAML filteredYAML, err := yaml.Marshal(filteredConfig) if err != nil { return fmt.Errorf("error re-marshaling filtered YAML: %v", err) } // Second pass: Decode with strict validation (disallow unknown fields within valid sections) decoder := yaml.NewDecoder(bytes.NewReader(filteredYAML), yaml.DisallowUnknownField()) err = decoder.Decode(&Config) if err != nil { return fmt.Errorf("error unmarshaling YAML data: %v", err) } loadEnvConfig() return nil } func ValidateConfig(config Settings) error { validate := validator.New() // Register custom validator for file permissions err := validate.RegisterValidation("file_permission", validateFilePermission) if err != nil { return fmt.Errorf("could not register file_permission validator: %v", err) } err = validate.Struct(Config) if err != nil { return fmt.Errorf("could not validate config: %v", err) } return nil } // validateFilePermission validates that a string is a valid Unix octal file permission (3-4 digits, 0-7) func validateFilePermission(fl validator.FieldLevel) bool { value := fl.Field().String() // Must be 3 or 4 characters long if len(value) < 3 || len(value) > 4 { return false } // All characters must be octal digits (0-7) for _, char := range value { if char < '0' || char > '7' { return false } } return true } func loadEnvConfig() { adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD") if ok { logger.Info("Using admin password from FILEBROWSER_ADMIN_PASSWORD environment variable") Config.Auth.AdminPassword = adminPassword } officeSecret, ok := os.LookupEnv("FILEBROWSER_ONLYOFFICE_SECRET") if ok { logger.Info("Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable") Config.Integrations.OnlyOffice.Secret = officeSecret } ffmpegPath, ok := os.LookupEnv("FILEBROWSER_FFMPEG_PATH") if ok { Config.Integrations.Media.FfmpegPath = ffmpegPath } oidcClientId := os.Getenv("FILEBROWSER_OIDC_CLIENT_ID") if oidcClientId != "" { Config.Auth.Methods.OidcAuth.ClientID = oidcClientId logger.Info("Using OIDC Client ID from FILEBROWSER_OIDC_CLIENT_ID environment variable") } oidcClientSecret := os.Getenv("FILEBROWSER_OIDC_CLIENT_SECRET") if oidcClientSecret != "" { Config.Auth.Methods.OidcAuth.ClientSecret = oidcClientSecret logger.Info("Using OIDC Client Secret from FILEBROWSER_OIDC_CLIENT_SECRET environment variable") } jwtTokenSecret := os.Getenv("FILEBROWSER_JWT_TOKEN_SECRET") if jwtTokenSecret != "" { Config.Auth.Key = jwtTokenSecret logger.Info("Using JWT Token Secret from FILEBROWSER_JWT_TOKEN_SECRET environment variable") } totpSecret := os.Getenv("FILEBROWSER_TOTP_SECRET") if totpSecret != "" { Config.Auth.TotpSecret = totpSecret logger.Info("Using TOTP Secret from FILEBROWSER_TOTP_SECRET environment variable") } recaptchaSecret := os.Getenv("FILEBROWSER_RECAPTCHA_SECRET") if recaptchaSecret != "" { Config.Auth.Methods.PasswordAuth.Recaptcha.Secret = recaptchaSecret logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable") } ldapUserPassword := os.Getenv("FILEBROWSER_LDAP_USER_PASSWORD") if ldapUserPassword != "" { Config.Auth.Methods.LdapAuth.UserPassword = ldapUserPassword logger.Info("Using LDAP bind password from FILEBROWSER_LDAP_USER_PASSWORD environment variable") } } func SetDefaults(generate bool) Settings { // get number of CPUs available numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available cpus := runtime.NumCPU() if cpus > 0 && !generate { numCpus = cpus } database := os.Getenv("FILEBROWSER_DATABASE") if database == "" { database = "database.db" } if _, err := os.Stat(database); os.IsNotExist(err) { logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.") } s := Settings{ Server: Server{ Port: 80, NumImageProcessors: numCpus, BaseURL: "", Database: database, SourceMap: map[string]*Source{}, NameToSource: map[string]*Source{}, CacheDir: "tmp", MaxArchiveSizeGB: 20, IndexSqlConfig: IndexSqlConfig{ WalMode: false, BatchSize: 1000, CacheSizeMB: 32, DisableReuse: false, StartupIntegrityCheck: IndexStartupIntegrityQuickCheck, }, Filesystem: Filesystem{ CreateFilePermission: "644", CreateDirectoryPermission: "755", }, }, Auth: Auth{ AdminUsername: "admin", TokenExpirationHours: 2, Methods: LoginMethods{ PasswordAuth: PasswordAuthConfig{ Enabled: true, MinLength: 5, Signup: false, }, }, }, Frontend: Frontend{ Name: "FileBrowser Quantum", }, UserDefaults: UserDefaults{ // New organized structure Sidebar: UserDefaultsSidebar{ DisableQuickToggles: false, HideFileActions: false, DisableHideOnPreview: false, Sticky: true, HideFiles: false, ShowTools: boolPtr(true), }, Listing: UserDefaultsListing{ DeleteWithoutConfirming: false, DateFormat: false, ShowHidden: false, QuickDownload: false, ShowSelectMultiple: false, SingleClick: false, HideFileExt: "", ShowCopyPath: false, DeleteAfterArchive: true, ViewMode: "normal", GallerySize: 3, }, Preview: UserDefaultsPreview{ Image: boolPtr(true), Video: boolPtr(true), Audio: boolPtr(true), MotionVideoPreview: boolPtr(true), Office: boolPtr(true), PopUp: boolPtr(true), DisablePreviewExt: "", HighQuality: boolPtr(true), Folder: boolPtr(true), Models: boolPtr(true), }, FileViewer: UserDefaultsFileViewer{ EditorQuickSave: false, AutoplayMedia: boolPtr(true), DisableViewingExt: "", DisableOnlyOfficeExt: ".md .txt .pdf .html .xml", PreferEditorForMarkdown: false, DebugOffice: false, DefaultMediaPlayer: false, }, Search: UserDefaultsSearch{ DisableOptions: false, }, UI: UserDefaultsUI{ DarkMode: boolPtr(true), ThemeColor: "var(--blue)", CustomTheme: "", Locale: "en", }, FileLoading: users.FileLoading{ MaxConcurrent: 10, UploadChunkSize: 10, // 10MB DownloadChunkSize: 0, // 0MB, default to no chunking }, Account: UserDefaultsAccount{ Permissions: UserDefaultsAccountPermissions{ Api: false, Admin: false, Modify: false, Share: false, Realtime: false, Delete: false, Create: false, Download: boolPtr(true), // defaults to true }, LockPassword: false, DisableSettings: false, LoginMethod: "", DisableUpdateNotifications: false, }, }, } // Initialize ImagePreview map with defaults s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool) s.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled s.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled // Initialize VideoPreview map with defaults (all enabled) s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool) for _, t := range AllVideoPreviewTypes { s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true) } return s } // validateCustomImage validates a custom image file path and returns the absolute path or error func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) { // Get absolute path absolutePath, err = filepath.Abs(configPath) if err != nil { return "", fmt.Errorf("could not resolve path: %w", err) } // Check if file exists _, err = os.Stat(absolutePath) if err != nil { return "", fmt.Errorf("could not access file: %w", err) } // Validate file format ext := strings.ToLower(filepath.Ext(absolutePath)) validFormat := false for _, format := range allowedFormats { if ext == format { validFormat = true break } } if !validFormat { return "", fmt.Errorf("unsupported format '%s', supported formats: %v", ext, allowedFormats) } return absolutePath, nil } func loadCustomFavicon() { const imageName = "favicon" allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"} // Set default embedded favicon path Env.FaviconEmbeddedPath = "img/icons/favicon.svg" // Check if a custom favicon path is configured if Config.Frontend.Favicon == "" { Env.FaviconPath = Env.FaviconEmbeddedPath Env.FaviconIsCustom = false return } // Validate custom favicon validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats) if err != nil { logger.Warningf("Custom favicon validation failed: %v, using default", err) Config.Frontend.Favicon = "" Env.FaviconPath = Env.FaviconEmbeddedPath Env.FaviconIsCustom = false return } // Update to validated path and mark as custom Config.Frontend.Favicon = validatedPath Env.FaviconPath = validatedPath Env.FaviconIsCustom = true logger.Infof("Using custom favicon: %s", Env.FaviconPath) // PWA and platform PNGs are generated at server startup from this path (SVG uses a raster // sidecar next to the .svg when present; see icons.GeneratePWAIcons). } // PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir). func PWAIconsCacheDir() string { return filepath.Join(Config.Server.CacheDir, "icons") } // Where transient archives for multi-request (Range) downloads are stored to support chunked downloads. func DownloadCacheDir() string { return filepath.Join(Config.Server.CacheDir, "downloads") } // PrepareDownloadSpoolDir creates the download spool directory and removes any leftover dl-archive-* func PrepareDownloadSpoolDir() error { if err := os.MkdirAll(DownloadCacheDir(), fileutils.PermDir); err != nil { return err } return fileutils.ClearDirectoryContents(DownloadCacheDir()) } func loadLoginIcon() { const imageName = "login icon" allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"} // Set default embedded icon path - just use favicon.svg (light/dark handled by CSS) Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Check if a custom login icon path is configured if Config.Frontend.LoginIcon == "" { Env.LoginIconPath = Env.LoginIconEmbeddedPath Env.LoginIconIsCustom = false return } // Validate custom login icon validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats) if err != nil { logger.Warningf("Custom login icon validation failed: %v, using default", err) Env.LoginIconPath = Env.LoginIconEmbeddedPath Env.LoginIconIsCustom = false return } // Update to validated path and mark as custom Env.LoginIconPath = validatedPath Env.LoginIconIsCustom = true logger.Infof("Using custom login icon: %s", Env.LoginIconPath) } // setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups func setConditionals(config *Source) { // Merge rules from both old format (Conditionals.ItemRules) and new format (Rules) rules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...) // Initialize the maps structure (only exact match maps for Names) resolved := ResolvedRulesConfig{ FileNames: make(map[string]ConditionalRule), FolderNames: make(map[string]ConditionalRule), FilePaths: make(map[string]ConditionalRule), FolderPaths: make(map[string]ConditionalRule), FileEndsWith: make([]ConditionalRule, 0), FolderEndsWith: make([]ConditionalRule, 0), FileStartsWith: make([]ConditionalRule, 0), FolderStartsWith: make([]ConditionalRule, 0), NeverWatchPaths: make(map[string]struct{}), IncludeRootItems: make(map[string]struct{}), IgnoreAllHidden: false, IgnoreAllZeroSizeFolders: false, IgnoreAllSymlinks: false, IndexingDisabled: false, NoRules: len(rules) == 0, } // Process all rules and infer global flags from root-level rules for _, rule := range rules { // Check if this is a root-level rule (folderPath == "/") // Root-level rules with ignoreHidden/ignoreZeroSizeFolders/viewable set global flags isRootLevelRule := rule.FolderPath == "/" // Infer global flags from root-level rules if isRootLevelRule { if rule.IgnoreHidden { resolved.IgnoreAllHidden = true } if rule.IgnoreSymlinks { resolved.IgnoreAllSymlinks = true } if rule.IgnoreZeroSizeFolders { resolved.IgnoreAllZeroSizeFolders = true } if rule.Viewable { resolved.IndexingDisabled = true } } // Build optimized lookup structures if rule.FileEndsWith != "" { resolved.FileEndsWith = append(resolved.FileEndsWith, rule) } if rule.FolderEndsWith != "" { resolved.FolderEndsWith = append(resolved.FolderEndsWith, rule) } if rule.FileStartsWith != "" { resolved.FileStartsWith = append(resolved.FileStartsWith, rule) } if rule.FolderStartsWith != "" { resolved.FolderStartsWith = append(resolved.FolderStartsWith, rule) } if rule.FilePath != "" { resolved.FilePaths[rule.FilePath] = rule } if rule.FolderPath != "" { resolved.FolderPaths[rule.FolderPath] = rule } if rule.NeverWatchPath != "" { resolved.NeverWatchPaths[rule.NeverWatchPath] = struct{}{} } if rule.IncludeRootItem != "" { resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{} } if rule.FileNames != "" { resolved.FileNames[rule.FileNames] = rule } if rule.FolderNames != "" { resolved.FolderNames[rule.FolderNames] = rule } if rule.FileName != "" { resolved.FileNames[rule.FileName] = rule } if rule.FolderName != "" { resolved.FolderNames[rule.FolderName] = rule } } config.Config.ResolvedRules = resolved } func modifyExcludeInclude(config *Source) { // Helper to normalize a full path value (FilePaths, FolderPaths) // These always start with "/" and match against full index paths normalizeFullPath := func(value string, checkExists bool) string { if value == "" { return "" } normalized := "/" + strings.Trim(value, "/") // check if file/folder exists if checkExists { realPath, err := filepath.Abs(config.Path + normalized) if err != nil { logger.Warningf("could not get absolute path for %v: %v", normalized, err) return normalized } if _, err := os.Stat(realPath); os.IsNotExist(err) { logger.Warningf("configured exclude/include path %v does not exist", realPath) } } return normalized } // Helper to normalize a name-based value (FileNames, FolderNames, StartsWith, EndsWith) // These match against baseName, so no leading slash normalizeName := func(value string) string { // Just trim slashes - names shouldn't have slashes return strings.Trim(value, "/") } // Normalize []ConditionalRule slices for full paths // Handle both old format (Conditionals.ItemRules) and new format (Rules) allRules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...) for i, rule := range allRules { // normalize full paths allRules[i].FilePath = normalizeFullPath(rule.FilePath, true) // FolderPath gets trailing slash for proper prefix matching if rule.FolderPath != "" { normalized := normalizeFullPath(rule.FolderPath, true) if normalized != "/" && !strings.HasSuffix(normalized, "/") { normalized = normalized + "/" } allRules[i].FolderPath = normalized } allRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true) if rule.IncludeRootItem != "" { normalized := normalizeFullPath(rule.IncludeRootItem, true) if normalized != "/" && !strings.HasSuffix(normalized, "/") { normalized = normalized + "/" } allRules[i].IncludeRootItem = normalized } // normalize names allRules[i].FileNames = normalizeName(rule.FileNames) allRules[i].FolderNames = normalizeName(rule.FolderNames) allRules[i].FileName = normalizeName(rule.FileName) allRules[i].FolderName = normalizeName(rule.FolderName) } // Update the original slices with normalized values itemRulesLen := len(config.Config.Conditionals.ItemRules) config.Config.Conditionals.ItemRules = allRules[:itemRulesLen] config.Config.Rules = allRules[itemRulesLen:] }