453 lines
13 KiB
Go
453 lines
13 KiB
Go
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
|
|
|
|
// Email (SMTP) settings
|
|
SMTPHost string
|
|
SMTPPort int
|
|
SMTPUsername string
|
|
SMTPPassword string
|
|
SMTPSender string
|
|
SMTPUseTLS 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": "true",
|
|
"MFA_ENABLED_BY_DEFAULT": "false",
|
|
},
|
|
"EMAIL": {
|
|
"SMTP_HOST": "",
|
|
"SMTP_PORT": "587",
|
|
"SMTP_USERNAME": "",
|
|
"SMTP_PASSWORD": "",
|
|
"SMTP_SENDER": "",
|
|
"SMTP_USE_TLS": "true",
|
|
},
|
|
"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 EMAIL (SMTP) section
|
|
emailSection := cfg.Section("EMAIL")
|
|
config.SMTPHost = emailSection.Key("SMTP_HOST").String()
|
|
config.SMTPPort, _ = emailSection.Key("SMTP_PORT").Int()
|
|
config.SMTPUsername = emailSection.Key("SMTP_USERNAME").String()
|
|
config.SMTPPassword = emailSection.Key("SMTP_PASSWORD").String()
|
|
config.SMTPSender = emailSection.Key("SMTP_SENDER").String()
|
|
config.SMTPUseTLS, _ = emailSection.Key("SMTP_USE_TLS").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 "EMAIL":
|
|
switch key {
|
|
case "SMTP_HOST":
|
|
c.SMTPHost = value
|
|
case "SMTP_PORT":
|
|
if v, err := strconv.Atoi(value); err == nil {
|
|
c.SMTPPort = v
|
|
}
|
|
case "SMTP_USERNAME":
|
|
c.SMTPUsername = value
|
|
case "SMTP_PASSWORD":
|
|
c.SMTPPassword = value
|
|
case "SMTP_SENDER":
|
|
c.SMTPSender = value
|
|
case "SMTP_USE_TLS":
|
|
c.SMTPUseTLS = 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)
|
|
}
|