package config import ( "fmt" "math" "os" "path/filepath" "strconv" "strings" ) type Source string const ( SourceDefault Source = "default" SourceEnv Source = "environment" SourceDB Source = "db override" ) type AdminEnabledMode string const ( AdminEnabledAuto AdminEnabledMode = "auto" AdminEnabledTrue AdminEnabledMode = "true" AdminEnabledFalse AdminEnabledMode = "false" ) const ( SettingGuestUploadsEnabled = "guest_uploads_enabled" SettingAPIEnabled = "api_enabled" SettingZipDownloadsEnabled = "zip_downloads_enabled" SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled" SettingOneTimeDownloadExpirySecs = "one_time_download_expiry_seconds" SettingRenewOnAccessEnabled = "renew_on_access_enabled" SettingRenewOnDownloadEnabled = "renew_on_download_enabled" SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds" SettingMaxGuestExpirySecs = "max_guest_expiry_seconds" SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes" SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes" SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes" SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes" SettingSessionTTLSeconds = "session_ttl_seconds" SettingBoxPollIntervalMS = "box_poll_interval_ms" SettingThumbnailBatchSize = "thumbnail_batch_size" SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingDataDir = "data_dir" ) type SettingType string const ( SettingTypeBool SettingType = "bool" SettingTypeInt64 SettingType = "int64" SettingTypeInt SettingType = "int" SettingTypeText SettingType = "text" ) type SettingDefinition struct { Key string EnvName string Label string Type SettingType Editable bool HardLimit bool Minimum int64 } type SettingRow struct { Definition SettingDefinition Value string Source Source } type Config struct { DataDir string UploadsDir string DBDir string AdminPassword string AdminUsername string AdminEmail string AdminEnabled AdminEnabledMode AdminCookieSecure bool AllowAdminSettingsOverride bool GuestUploadsEnabled bool APIEnabled bool ZipDownloadsEnabled bool OneTimeDownloadsEnabled bool OneTimeDownloadExpirySeconds int64 RenewOnAccessEnabled bool RenewOnDownloadEnabled bool DefaultGuestExpirySeconds int64 MaxGuestExpirySeconds int64 GlobalMaxFileSizeBytes int64 GlobalMaxBoxSizeBytes int64 DefaultUserMaxFileSizeBytes int64 DefaultUserMaxBoxSizeBytes int64 SessionTTLSeconds int64 BoxPollIntervalMS int ThumbnailBatchSize int ThumbnailIntervalSeconds int sources map[string]Source values map[string]string } var Definitions = []SettingDefinition{ {Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true}, {Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingOneTimeDownloadExpirySecs, EnvName: "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", Label: "One-time download expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0}, {Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0}, {Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60}, {Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000}, {Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1}, } func Load() (*Config, error) { cfg := &Config{ DataDir: "./data", AdminUsername: "admin", AdminEnabled: AdminEnabledAuto, AllowAdminSettingsOverride: true, GuestUploadsEnabled: true, APIEnabled: true, ZipDownloadsEnabled: true, OneTimeDownloadsEnabled: true, OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60, DefaultGuestExpirySeconds: 10, MaxGuestExpirySeconds: 48 * 60 * 60, SessionTTLSeconds: 24 * 60 * 60, BoxPollIntervalMS: 5000, ThumbnailBatchSize: 10, ThumbnailIntervalSeconds: 30, sources: make(map[string]Source), values: make(map[string]string), } cfg.captureDefaults() if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil { return nil, err } if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil { return nil, err } if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil { return nil, err } if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil { return nil, err } if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" { mode := AdminEnabledMode(strings.ToLower(raw)) if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse { return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false") } cfg.AdminEnabled = mode } if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil { return nil, err } if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil { return nil, err } envBools := []struct { key string name string target *bool }{ {SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled}, {SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled}, {SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled}, {SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled}, {SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled}, {SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled}, } for _, item := range envBools { if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil { return nil, err } } envInt64s := []struct { key string name string min int64 target *int64 }{ {SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds}, {SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, {SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds}, {SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds}, } for _, item := range envInt64s { if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil { return nil, err } } sizeEnvVars := []struct { key string mbName string bytesName string target *int64 }{ {SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes}, {SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes}, {SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes}, {SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes}, } for _, item := range sizeEnvVars { if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil { return nil, err } } envInts := []struct { key string name string min int target *int }{ {SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS}, {SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize}, {SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds}, } for _, item := range envInts { if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil { return nil, err } } cfg.DataDir = filepath.Clean(cfg.DataDir) if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" { cfg.DataDir = "data" } if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" { return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty") } cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail) cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads") cfg.DBDir = filepath.Join(cfg.DataDir, "db") cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir)) return cfg, nil } func (cfg *Config) EnsureDirectories() error { for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} { if err := os.MkdirAll(path, 0755); err != nil { return fmt.Errorf("create %s: %w", path, err) } } return nil } func (cfg *Config) ApplyOverrides(overrides map[string]string) error { if !cfg.AllowAdminSettingsOverride { return nil } for key, value := range overrides { if err := cfg.ApplyOverride(key, value); err != nil { return err } } return nil } func (cfg *Config) ApplyOverride(key string, value string) error { def, ok := Definition(key) if !ok { return fmt.Errorf("unknown setting %q", key) } if !def.Editable || def.HardLimit { return fmt.Errorf("setting %q cannot be changed from the admin UI", key) } switch def.Type { case SettingTypeBool: parsed, err := parseBool(value) if err != nil { return fmt.Errorf("%s: %w", key, err) } cfg.assignBool(key, parsed, SourceDB) case SettingTypeInt64: parsed, err := parseInt64(value, def.Minimum) if err != nil { return fmt.Errorf("%s: %w", key, err) } cfg.assignInt64(key, parsed, SourceDB) case SettingTypeInt: parsed64, err := parseInt64(value, def.Minimum) if err != nil { return fmt.Errorf("%s: %w", key, err) } cfg.assignInt(key, int(parsed64), SourceDB) default: return fmt.Errorf("setting %q is not runtime editable", key) } return nil } func (cfg *Config) SettingRows() []SettingRow { rows := make([]SettingRow, 0, len(Definitions)) for _, def := range Definitions { rows = append(rows, SettingRow{ Definition: def, Value: cfg.values[def.Key], Source: cfg.sourceFor(def.Key), }) } return rows } func (cfg *Config) Source(key string) Source { return cfg.sourceFor(key) } func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool { switch cfg.AdminEnabled { case AdminEnabledFalse: return false case AdminEnabledTrue: return hasAdminUser default: return hasAdminUser } } func Definition(key string) (SettingDefinition, bool) { for _, def := range Definitions { if def.Key == key { return def, true } } return SettingDefinition{}, false } func EditableDefinitions() []SettingDefinition { defs := make([]SettingDefinition, 0, len(Definitions)) for _, def := range Definitions { if def.Editable && !def.HardLimit { defs = append(defs, def) } } return defs } func (cfg *Config) captureDefaults() { cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault) cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault) cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault) cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault) cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault) cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault) cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault) cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault) cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault) cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault) cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault) cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault) cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault) cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault) cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault) cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault) cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault) cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault) } func (cfg *Config) applyStringEnv(key string, name string, target *string) error { raw := os.Getenv(name) if raw == "" { return nil } *target = raw if key != "" { cfg.setValue(key, raw, SourceEnv) } return nil } func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return nil } parsed, err := parseBool(raw) if err != nil { return fmt.Errorf("%s: %w", name, err) } *target = parsed if key != "" { cfg.setValue(key, formatBool(parsed), SourceEnv) } return nil } func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return nil } parsed, err := parseInt64(raw, min) if err != nil { return fmt.Errorf("%s: %w", name, err) } *target = parsed if key != "" { cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv) } return nil } func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error { if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" { parsed, err := parseInt64(rawBytes, min) if err != nil { return fmt.Errorf("%s: %w", bytesName, err) } *target = parsed cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv) return nil } rawMB := strings.TrimSpace(os.Getenv(mbName)) if rawMB == "" { return nil } parsedMB, err := parseInt64(rawMB, min) if err != nil { return fmt.Errorf("%s: %w", mbName, err) } if parsedMB > math.MaxInt64/(1024*1024) { return fmt.Errorf("%s: is too large", mbName) } parsedBytes := parsedMB * 1024 * 1024 *target = parsedBytes cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv) return nil } func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return nil } parsed, err := parseInt(raw, min) if err != nil { return fmt.Errorf("%s: %w", name, err) } *target = parsed if key != "" { cfg.setValue(key, strconv.Itoa(parsed), SourceEnv) } return nil } func (cfg *Config) assignBool(key string, value bool, source Source) { switch key { case SettingGuestUploadsEnabled: cfg.GuestUploadsEnabled = value case SettingAPIEnabled: cfg.APIEnabled = value case SettingZipDownloadsEnabled: cfg.ZipDownloadsEnabled = value case SettingOneTimeDownloadsEnabled: cfg.OneTimeDownloadsEnabled = value case SettingRenewOnAccessEnabled: cfg.RenewOnAccessEnabled = value case SettingRenewOnDownloadEnabled: cfg.RenewOnDownloadEnabled = value } cfg.setValue(key, formatBool(value), source) } func (cfg *Config) assignInt64(key string, value int64, source Source) { switch key { case SettingDefaultGuestExpirySecs: cfg.DefaultGuestExpirySeconds = value case SettingMaxGuestExpirySecs: cfg.MaxGuestExpirySeconds = value case SettingOneTimeDownloadExpirySecs: cfg.OneTimeDownloadExpirySeconds = value case SettingDefaultUserMaxFileBytes: cfg.DefaultUserMaxFileSizeBytes = value case SettingDefaultUserMaxBoxBytes: cfg.DefaultUserMaxBoxSizeBytes = value case SettingSessionTTLSeconds: cfg.SessionTTLSeconds = value } cfg.setValue(key, strconv.FormatInt(value, 10), source) } func (cfg *Config) assignInt(key string, value int, source Source) { switch key { case SettingBoxPollIntervalMS: cfg.BoxPollIntervalMS = value case SettingThumbnailBatchSize: cfg.ThumbnailBatchSize = value case SettingThumbnailIntervalSeconds: cfg.ThumbnailIntervalSeconds = value } cfg.setValue(key, strconv.Itoa(value), source) } func (cfg *Config) setValue(key string, value string, source Source) { if key == "" { return } cfg.values[key] = value cfg.sources[key] = source } func (cfg *Config) sourceFor(key string) Source { source, ok := cfg.sources[key] if !ok { return SourceDefault } return source } func parseBool(value string) (bool, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "t", "true", "y", "yes", "on": return true, nil case "0", "f", "false", "n", "no", "off": return false, nil default: return false, fmt.Errorf("must be a boolean") } } func parseInt64(value string, min int64) (int64, error) { parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { return 0, fmt.Errorf("must be an integer") } if parsed < min { return 0, fmt.Errorf("must be at least %d", min) } return parsed, nil } func parseInt(value string, min int) (int, error) { parsed64, err := parseInt64(value, int64(min)) if err != nil { return 0, err } if parsed64 > int64(^uint(0)>>1) { return 0, fmt.Errorf("is too large") } return int(parsed64), nil } func formatBool(value bool) string { if value { return "true" } return "false" }