package config import ( "fmt" "os" "path/filepath" "strconv" "strings" "gopkg.in/ini.v1" ) type Config struct { // SERVER equivalent settings Host string Port int SecretKey string Debug bool MaxContentLength int64 // in bytes URLPrefix string // MD Notes App settings AppName string NotesDir string NotesDirHideSidepane []string NotesDirSkip []string ImagesHide bool ImageStorageMode int ImageStoragePath string ImageSubfolderName string AllowedImageExtensions []string AllowedFileExtensions []string // Visibility settings ShowImagesInTree bool ShowFilesInTree bool ShowImagesInFolder bool ShowFilesInFolder bool // Database settings DBType string DBPath string // Auth settings RequireAdminActivation bool RequireEmailConfirmation bool MFAEnabledByDefault bool // Security settings (failed-login thresholds and auto-ban config) PwdFailuresThreshold int MFAFailuresThreshold int FailuresWindowMinutes int AutoBanDurationHours int AutoBanPermanent bool } var defaultConfig = map[string]map[string]string{ "SERVER": { "HOST": "0.0.0.0", "PORT": "3000", "SECRET_KEY": "change-this-secret-key", "DEBUG": "false", "MAX_CONTENT_LENGTH": "16", // in MB "URL_PREFIX": "", }, "MD_NOTES_APP": { "APP_NAME": "Gobsidian", "NOTES_DIR": "notes", "NOTES_DIR_HIDE_SIDEPANE": "attached, images", "NOTES_DIR_SKIP": "secret, private", "IMAGES_HIDE": "false", "IMAGE_STORAGE_MODE": "1", "IMAGE_STORAGE_PATH": "images", "IMAGE_SUBFOLDER_NAME": "attached", "ALLOWED_IMAGE_EXTENSIONS": "jpg, jpeg, png, webp, gif", "ALLOWED_FILE_EXTENSIONS": "txt, pdf, html, json, yaml, yml, conf, csv, cmd, bat, sh", // Visibility defaults "SHOW_IMAGES_IN_TREE": "false", "SHOW_FILES_IN_TREE": "true", "SHOW_IMAGES_IN_FOLDER": "true", "SHOW_FILES_IN_FOLDER": "true", }, "DATABASE": { "TYPE": "sqlite", "PATH": "data/gobsidian.db", }, "AUTH": { "REQUIRE_ADMIN_ACTIVATION": "true", "REQUIRE_EMAIL_CONFIRMATION": "false", "MFA_ENABLED_BY_DEFAULT": "false", }, "SECURITY": { "PWD_FAILURES_THRESHOLD": "5", "MFA_FAILURES_THRESHOLD": "10", "FAILURES_WINDOW_MINUTES": "30", "AUTO_BAN_DURATION_HOURS": "12", "AUTO_BAN_PERMANENT": "false", }, } // exeDir returns the directory containing the running executable. func exeDir() string { exe, err := os.Executable() if err != nil { wd, _ := os.Getwd() return wd } return filepath.Dir(exe) } func Load() (*Config, error) { baseDir := exeDir() // settings.ini lives next to the executable configPath := filepath.Join(baseDir, "settings.ini") // Ensure config file exists if err := ensureConfigFile(configPath); err != nil { return nil, fmt.Errorf("failed to ensure config file: %w", err) } // Load configuration cfg, err := ini.Load(configPath) if err != nil { return nil, fmt.Errorf("failed to load config file: %w", err) } config := &Config{} // Load SERVER section SERVERSection := cfg.Section("SERVER") config.Host = SERVERSection.Key("HOST").String() config.Port, _ = SERVERSection.Key("PORT").Int() config.SecretKey = SERVERSection.Key("SECRET_KEY").String() config.Debug, _ = SERVERSection.Key("DEBUG").Bool() maxContentMB, _ := SERVERSection.Key("MAX_CONTENT_LENGTH").Int() config.MaxContentLength = int64(maxContentMB) * 1024 * 1024 // Convert MB to bytes // Normalize URL prefix: "" or starts with '/' and no trailing '/' rawPrefix := strings.TrimSpace(SERVERSection.Key("URL_PREFIX").String()) if rawPrefix == "/" { rawPrefix = "" } if rawPrefix != "" { if !strings.HasPrefix(rawPrefix, "/") { rawPrefix = "/" + rawPrefix } rawPrefix = strings.TrimRight(rawPrefix, "/") } config.URLPrefix = rawPrefix // Load MD_NOTES_APP section notesSection := cfg.Section("MD_NOTES_APP") config.AppName = notesSection.Key("APP_NAME").String() config.NotesDir = notesSection.Key("NOTES_DIR").String() // Parse comma-separated lists config.NotesDirHideSidepane = parseCommaSeparated(notesSection.Key("NOTES_DIR_HIDE_SIDEPANE").String()) config.NotesDirSkip = parseCommaSeparated(notesSection.Key("NOTES_DIR_SKIP").String()) config.AllowedImageExtensions = parseCommaSeparated(notesSection.Key("ALLOWED_IMAGE_EXTENSIONS").String()) config.AllowedFileExtensions = parseCommaSeparated(notesSection.Key("ALLOWED_FILE_EXTENSIONS").String()) config.ImagesHide, _ = notesSection.Key("IMAGES_HIDE").Bool() // New visibility flags (fallback to legacy IMAGES_HIDE for folder images if keys missing) config.ShowImagesInTree, _ = notesSection.Key("SHOW_IMAGES_IN_TREE").Bool() config.ShowFilesInTree, _ = notesSection.Key("SHOW_FILES_IN_TREE").Bool() if key := notesSection.Key("SHOW_IMAGES_IN_FOLDER"); key.String() != "" { config.ShowImagesInFolder, _ = key.Bool() } else { // fallback: if IMAGES_HIDE true => not shown config.ShowImagesInFolder = !config.ImagesHide } if key := notesSection.Key("SHOW_FILES_IN_FOLDER"); key.String() != "" { config.ShowFilesInFolder, _ = key.Bool() } else { config.ShowFilesInFolder = true } config.ImageStorageMode, _ = notesSection.Key("IMAGE_STORAGE_MODE").Int() config.ImageStoragePath = notesSection.Key("IMAGE_STORAGE_PATH").String() config.ImageSubfolderName = notesSection.Key("IMAGE_SUBFOLDER_NAME").String() // Convert relative paths to be next to the executable if !filepath.IsAbs(config.NotesDir) { config.NotesDir = filepath.Join(baseDir, config.NotesDir) } if !filepath.IsAbs(config.ImageStoragePath) && config.ImageStorageMode == 2 { config.ImageStoragePath = filepath.Join(baseDir, config.ImageStoragePath) } // Ensure these directories exist if err := os.MkdirAll(config.NotesDir, 0o755); err != nil { return nil, fmt.Errorf("failed to create notes directory: %w", err) } if config.ImageStorageMode == 2 { if err := os.MkdirAll(config.ImageStoragePath, 0o755); err != nil { return nil, fmt.Errorf("failed to create image storage directory: %w", err) } } // Load DATABASE section dbSection := cfg.Section("DATABASE") config.DBType = strings.ToLower(strings.TrimSpace(dbSection.Key("TYPE").String())) config.DBPath = dbSection.Key("PATH").String() if config.DBType == "sqlite" { if !filepath.IsAbs(config.DBPath) { config.DBPath = filepath.Join(baseDir, config.DBPath) } // ensure parent dir exists if err := os.MkdirAll(filepath.Dir(config.DBPath), 0o755); err != nil { return nil, fmt.Errorf("failed to create db directory: %w", err) } } // Load AUTH section authSection := cfg.Section("AUTH") config.RequireAdminActivation, _ = authSection.Key("REQUIRE_ADMIN_ACTIVATION").Bool() config.RequireEmailConfirmation, _ = authSection.Key("REQUIRE_EMAIL_CONFIRMATION").Bool() config.MFAEnabledByDefault, _ = authSection.Key("MFA_ENABLED_BY_DEFAULT").Bool() // Load SECURITY section secSection := cfg.Section("SECURITY") config.PwdFailuresThreshold, _ = secSection.Key("PWD_FAILURES_THRESHOLD").Int() config.MFAFailuresThreshold, _ = secSection.Key("MFA_FAILURES_THRESHOLD").Int() config.FailuresWindowMinutes, _ = secSection.Key("FAILURES_WINDOW_MINUTES").Int() config.AutoBanDurationHours, _ = secSection.Key("AUTO_BAN_DURATION_HOURS").Int() config.AutoBanPermanent, _ = secSection.Key("AUTO_BAN_PERMANENT").Bool() return config, nil } func ensureConfigFile(configPath string) error { // Check if file exists if _, err := os.Stat(configPath); os.IsNotExist(err) { // ensure parent dir exists if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return err } return createDefaultConfigFile(configPath) } // File exists, check if it has all required settings return updateConfigFile(configPath) } func createDefaultConfigFile(configPath string) error { cfg := ini.Empty() for sectionName, settings := range defaultConfig { section, err := cfg.NewSection(sectionName) if err != nil { return err } for key, value := range settings { section.NewKey(key, value) } } return cfg.SaveTo(configPath) } func updateConfigFile(configPath string) error { cfg, err := ini.Load(configPath) if err != nil { return err } updated := false for sectionName, settings := range defaultConfig { section := cfg.Section(sectionName) for key, defaultValue := range settings { if !section.HasKey(key) { section.NewKey(key, defaultValue) updated = true } } } if updated { return cfg.SaveTo(configPath) } return nil } func parseCommaSeparated(value string) []string { if value == "" { return []string{} } parts := strings.Split(value, ",") result := make([]string, 0, len(parts)) for _, part := range parts { if trimmed := strings.TrimSpace(part); trimmed != "" { result = append(result, trimmed) } } return result } func (c *Config) SaveSetting(section, key, value string) error { configPath := filepath.Join(exeDir(), "settings.ini") cfg, err := ini.Load(configPath) if err != nil { return err } sec := cfg.Section(section) sec.Key(key).SetValue(value) // Update in-memory config based on section and key switch section { case "SERVER": switch key { case "HOST": c.Host = value case "PORT": if port, err := strconv.Atoi(value); err == nil { c.Port = port } case "SECRET_KEY": c.SecretKey = value case "DEBUG": c.Debug = value == "true" case "MAX_CONTENT_LENGTH": if size, err := strconv.ParseInt(value, 10, 64); err == nil { c.MaxContentLength = size * 1024 * 1024 } case "URL_PREFIX": v := strings.TrimSpace(value) if v == "/" { v = "" } if v != "" { if !strings.HasPrefix(v, "/") { v = "/" + v } v = strings.TrimRight(v, "/") } c.URLPrefix = v } case "MD_NOTES_APP": switch key { case "APP_NAME": c.AppName = value case "NOTES_DIR": c.NotesDir = value case "NOTES_DIR_HIDE_SIDEPANE": c.NotesDirHideSidepane = parseCommaSeparated(value) case "NOTES_DIR_SKIP": c.NotesDirSkip = parseCommaSeparated(value) case "IMAGES_HIDE": c.ImagesHide = value == "true" case "IMAGE_STORAGE_MODE": if mode, err := strconv.Atoi(value); err == nil { c.ImageStorageMode = mode } case "IMAGE_STORAGE_PATH": c.ImageStoragePath = value case "IMAGE_SUBFOLDER_NAME": c.ImageSubfolderName = value case "ALLOWED_IMAGE_EXTENSIONS": c.AllowedImageExtensions = parseCommaSeparated(value) case "ALLOWED_FILE_EXTENSIONS": c.AllowedFileExtensions = parseCommaSeparated(value) case "SHOW_IMAGES_IN_TREE": c.ShowImagesInTree = value == "true" case "SHOW_FILES_IN_TREE": c.ShowFilesInTree = value == "true" case "SHOW_IMAGES_IN_FOLDER": c.ShowImagesInFolder = value == "true" case "SHOW_FILES_IN_FOLDER": c.ShowFilesInFolder = value == "true" } case "DATABASE": switch key { case "TYPE": c.DBType = strings.ToLower(strings.TrimSpace(value)) case "PATH": c.DBPath = value } case "AUTH": switch key { case "REQUIRE_ADMIN_ACTIVATION": c.RequireAdminActivation = value == "true" case "REQUIRE_EMAIL_CONFIRMATION": c.RequireEmailConfirmation = value == "true" case "MFA_ENABLED_BY_DEFAULT": c.MFAEnabledByDefault = value == "true" } case "SECURITY": switch key { case "PWD_FAILURES_THRESHOLD": if v, err := strconv.Atoi(value); err == nil { c.PwdFailuresThreshold = v } case "MFA_FAILURES_THRESHOLD": if v, err := strconv.Atoi(value); err == nil { c.MFAFailuresThreshold = v } case "FAILURES_WINDOW_MINUTES": if v, err := strconv.Atoi(value); err == nil { c.FailuresWindowMinutes = v } case "AUTO_BAN_DURATION_HOURS": if v, err := strconv.Atoi(value); err == nil { c.AutoBanDurationHours = v } case "AUTO_BAN_PERMANENT": c.AutoBanPermanent = value == "true" } } return cfg.SaveTo(configPath) }