diff --git a/Dockerfile b/Dockerfile index 4c99a87..66792ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,8 +50,8 @@ ENV WARPBOX_DATA_DIR=/app/data \ WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \ WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \ WARPBOX_ADMIN_ENABLED=true \ - WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \ - WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \ + WARPBOX_GLOBAL_MAX_FILE_SIZE_GB=2 \ + WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \ WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \ WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \ WARPBOX_BOX_POLL_INTERVAL_MS=5000 \ diff --git a/README.md b/README.md index df02718..06b8280 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,8 @@ go run ./cmd run --addr :3000 ## Configuration WarpBox loads defaults, applies environment variables at startup, then applies -safe admin settings overrides from BadgerDB. Hard storage and global limit -settings remain environment controlled. +safe admin settings overrides from BadgerDB. Storage path settings remain +environment controlled. | Variable | Default | What it does | | --- | ---: | --- | @@ -108,16 +108,16 @@ settings remain environment controlled. | `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. | | `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. | | `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. | -| `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` | `0` | Hard per-file cap; `0` means unlimited. | -| `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` | `0` | Hard per-box cap; `0` means unlimited. | -| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` | `0` | Default user file cap. | -| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` | `0` | Default user box cap. | +| `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB` | `0` | Per-file cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed, like `0.5`. | +| `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB` | `0` | Per-box cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed. | +| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` | `0` | Default user file cap in GB using `1024^3` conversion. | +| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` | `0` | Default user box cap in GB using `1024^3` conversion. | | `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. | | `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. | | `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. | | `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. | -Size limits also accept `_MB` variants for the same settings. +Legacy `_MB` and `_BYTES` size env names are still accepted for compatibility, but GB env names are the intended format now. GB input uses `1024^3` bytes so UI limits and displayed space stay consistent. Example: diff --git a/cmd/cmd_env.go b/cmd/cmd_env.go index ac06789..a326455 100644 --- a/cmd/cmd_env.go +++ b/cmd/cmd_env.go @@ -175,22 +175,6 @@ func buildExtraEnvRows(includeHidden bool) []envRow { {EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"}, } - sizePairs := []struct { - bytesEnv string - mbEnv string - label string - }{ - {"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "Global max file size"}, - {"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "Global max box size"}, - {"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "Default user max file size"}, - {"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "Default user max box size"}, - } - - for _, pair := range sizePairs { - extra = append(extra, envRow{EnvName: pair.bytesEnv, Key: pair.bytesEnv, Label: pair.label + " (bytes)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"}) - extra = append(extra, envRow{EnvName: pair.mbEnv, Key: pair.mbEnv, Label: pair.label + " (MB)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"}) - } - return extra } diff --git a/docs/tech.md b/docs/tech.md index 58ebc35..f28acbd 100644 --- a/docs/tech.md +++ b/docs/tech.md @@ -150,16 +150,16 @@ Primary environment variables: - `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` - `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` - `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` -- `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` -- `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` -- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` -- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` +- `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB` +- `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB` +- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` +- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` - `WARPBOX_SESSION_TTL_SECONDS` - `WARPBOX_BOX_POLL_INTERVAL_MS` - `WARPBOX_THUMBNAIL_BATCH_SIZE` - `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` -Size limit settings accept `_MB` or `_BYTES` env names. `WARPBOX_ADMIN_ENABLED` +Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED` accepts `auto`, `true`, or `false`. The HTTP listen address is configured through the CLI flag: diff --git a/lib/config/config_test.go b/lib/config/config_test.go index c32f84f..3b32d59 100644 --- a/lib/config/config_test.go +++ b/lib/config/config_test.go @@ -35,7 +35,7 @@ func TestEnvironmentOverrides(t *testing.T) { t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test") t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false") t.Setenv("WARPBOX_API_ENABLED", "false") - t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100") + t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "0.5") t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000") t.Setenv("WARPBOX_ADMIN_USERNAME", "root") t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true") @@ -51,7 +51,7 @@ func TestEnvironmentOverrides(t *testing.T) { if cfg.GuestUploadsEnabled || cfg.APIEnabled { t.Fatal("expected boolean environment overrides to be applied") } - if cfg.GlobalMaxFileSizeBytes != 100 { + if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 { t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes) } if cfg.BoxPollIntervalMS != 2000 { @@ -70,25 +70,25 @@ func TestEnvironmentOverrides(t *testing.T) { func TestMegabyteSizeEnvironmentOverrides(t *testing.T) { clearConfigEnv(t) - t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048") - t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "4096") + t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2") + t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "4") cfg, err := Load() if err != nil { t.Fatalf("Load returned error: %v", err) } - if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 { + if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 { t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes) } - if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 { + if cfg.GlobalMaxBoxSizeBytes != 4*1024*1024*1024 { t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes) } } -func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) { +func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T) { clearConfigEnv(t) - t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048") + t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2") t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100") cfg, err := Load() @@ -96,7 +96,7 @@ func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) { t.Fatalf("Load returned error: %v", err) } - if cfg.GlobalMaxFileSizeBytes != 100 { + if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 { t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes) } } @@ -145,10 +145,10 @@ func TestSettingsOverrideValidation(t *testing.T) { if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil { t.Fatal("expected negative expiry override to fail") } - if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err != nil { + if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "0.5"); err != nil { t.Fatalf("expected global max file size override to succeed, got %v", err) } - if cfg.GlobalMaxFileSizeBytes != 1 { + if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 { t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes) } if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil { @@ -175,12 +175,16 @@ func clearConfigEnv(t *testing.T) { "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", + "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", + "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", + "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", + "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_SESSION_TTL_SECONDS", diff --git a/lib/config/definitions.go b/lib/config/definitions.go index 66d014d..9f03059 100644 --- a/lib/config/definitions.go +++ b/lib/config/definitions.go @@ -12,10 +12,10 @@ var Definitions = []SettingDefinition{ {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: true, Minimum: 0}, - {Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: 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: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", Label: "Global max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0}, + {Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", Label: "Global max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0}, + {Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", Label: "Default user max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0}, + {Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", Label: "Default user max box size GB", Type: SettingTypeSizeGB, 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}, @@ -50,6 +50,7 @@ func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool { } func Definition(key string) (SettingDefinition, bool) { + key = NormalizeLegacySettingKey(key) for _, def := range Definitions { if def.Key == key { return def, true @@ -58,6 +59,35 @@ func Definition(key string) (SettingDefinition, bool) { return SettingDefinition{}, false } +func NormalizeLegacySettingKey(key string) string { + switch key { + case "global_max_file_size_bytes": + return SettingGlobalMaxFileSizeBytes + case "global_max_box_size_bytes": + return SettingGlobalMaxBoxSizeBytes + case "default_user_max_file_size_bytes": + return SettingDefaultUserMaxFileBytes + case "default_user_max_box_size_bytes": + return SettingDefaultUserMaxBoxBytes + default: + return key + } +} + +func NormalizeOverrideInput(key string, value string) (string, string, error) { + normalizedKey := NormalizeLegacySettingKey(key) + switch key { + case "global_max_file_size_bytes", "global_max_box_size_bytes", "default_user_max_file_size_bytes", "default_user_max_box_size_bytes": + parsed, err := parseInt64(value, 0) + if err != nil { + return normalizedKey, "", err + } + return normalizedKey, formatGigabytesFromBytes(parsed), nil + default: + return normalizedKey, value, nil + } +} + func EditableDefinitions() []SettingDefinition { defs := make([]SettingDefinition, 0, len(Definitions)) for _, def := range Definitions { diff --git a/lib/config/load.go b/lib/config/load.go index 92b353d..eebde15 100644 --- a/lib/config/load.go +++ b/lib/config/load.go @@ -2,7 +2,6 @@ package config import ( "fmt" - "math" "os" "path/filepath" "strconv" @@ -99,17 +98,18 @@ func Load() (*Config, error) { } sizeEnvVars := []struct { key string + gbName 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}, + {SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes}, + {SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes}, + {SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes}, + {SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "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 { + if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil { return nil, err } } @@ -164,10 +164,10 @@ func (cfg *Config) captureDefaults() { cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled)) cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10)) cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10)) - cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10)) - cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10)) - cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10)) - cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10)) + cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxFileSizeBytes)) + cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxBoxSizeBytes)) + cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxFileSizeBytes)) + cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxBoxSizeBytes)) cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10)) cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS)) cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize)) @@ -225,14 +225,23 @@ func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int return nil } -func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error { +func (cfg *Config) applySizeEnv(key string, gbName string, mbName string, bytesName string, min int64, target *int64) error { + if rawGB := strings.TrimSpace(os.Getenv(gbName)); rawGB != "" { + parsed, err := parseGigabytes(rawGB, float64(min)) + if err != nil { + return fmt.Errorf("%s: %w", gbName, err) + } + *target = parsed + cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv) + return nil + } 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) + cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv) return nil } @@ -244,12 +253,9 @@ func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName 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 + parsedBytes := parsedMB * 1000 * 1000 *target = parsedBytes - cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv) + cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), SourceEnv) return nil } diff --git a/lib/config/models.go b/lib/config/models.go index 9fbd0dc..118aa3a 100644 --- a/lib/config/models.go +++ b/lib/config/models.go @@ -27,10 +27,10 @@ const ( 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" + SettingGlobalMaxFileSizeBytes = "global_max_file_size_gb" + SettingGlobalMaxBoxSizeBytes = "global_max_box_size_gb" + SettingDefaultUserMaxFileBytes = "default_user_max_file_size_gb" + SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_gb" SettingSessionTTLSeconds = "session_ttl_seconds" SettingBoxPollIntervalMS = "box_poll_interval_ms" SettingThumbnailBatchSize = "thumbnail_batch_size" @@ -41,10 +41,11 @@ const ( type SettingType string const ( - SettingTypeBool SettingType = "bool" - SettingTypeInt64 SettingType = "int64" - SettingTypeInt SettingType = "int" - SettingTypeText SettingType = "text" + SettingTypeBool SettingType = "bool" + SettingTypeInt64 SettingType = "int64" + SettingTypeInt SettingType = "int" + SettingTypeText SettingType = "text" + SettingTypeSizeGB SettingType = "size_gb" ) type SettingDefinition struct { diff --git a/lib/config/override_store.go b/lib/config/override_store.go index 0ce479f..ba2f895 100644 --- a/lib/config/override_store.go +++ b/lib/config/override_store.go @@ -32,7 +32,15 @@ func ReadAdminSettingsOverrides(path string) (map[string]string, error) { if payload.Overrides == nil { return map[string]string{}, nil } - return payload.Overrides, nil + normalized := make(map[string]string, len(payload.Overrides)) + for key, value := range payload.Overrides { + nextKey, nextValue, err := NormalizeOverrideInput(key, value) + if err != nil { + return nil, err + } + normalized[nextKey] = nextValue + } + return normalized, nil } func WriteAdminSettingsOverrides(path string, overrides map[string]string) error { diff --git a/lib/config/overrides.go b/lib/config/overrides.go index db3f403..bd2f449 100644 --- a/lib/config/overrides.go +++ b/lib/config/overrides.go @@ -39,6 +39,12 @@ func (cfg *Config) ApplyOverride(key string, value string) error { return fmt.Errorf("%s: %w", key, err) } cfg.assignInt64(key, parsed, SourceDB) + case SettingTypeSizeGB: + parsed, err := parseGigabytes(value, float64(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 { @@ -87,6 +93,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) { case SettingSessionTTLSeconds: cfg.SessionTTLSeconds = value } + if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes { + cfg.setValue(key, formatGigabytesFromBytes(value), source) + return + } cfg.setValue(key, strconv.FormatInt(value, 10), source) } diff --git a/lib/config/parse.go b/lib/config/parse.go index 8add7b2..8efb44f 100644 --- a/lib/config/parse.go +++ b/lib/config/parse.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "math" "strconv" "strings" ) @@ -39,6 +40,46 @@ func parseInt(value string, min int) (int, error) { return int(parsed64), nil } +const bytesPerGigabyte = 1024 * 1024 * 1024 + +func parseGigabytes(value string, min float64) (int64, error) { + raw := strings.TrimSpace(value) + lower := strings.ToLower(raw) + if strings.HasSuffix(lower, "gb") { + raw = strings.TrimSpace(raw[:len(raw)-2]) + } + parsed, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, fmt.Errorf("must be a number of GB") + } + if parsed < min { + return 0, fmt.Errorf("must be at least %s", trimTrailingZeros(min)) + } + bytes := parsed * bytesPerGigabyte + if bytes > math.MaxInt64 { + return 0, fmt.Errorf("is too large") + } + return int64(math.Round(bytes)), nil +} + +func formatGigabytesFromBytes(bytes int64) string { + if bytes <= 0 { + return "0" + } + value := float64(bytes) / bytesPerGigabyte + return trimTrailingZeros(value) +} + +func trimTrailingZeros(value float64) string { + text := strconv.FormatFloat(value, 'f', 3, 64) + text = strings.TrimRight(text, "0") + text = strings.TrimRight(text, ".") + if text == "" { + return "0" + } + return text +} + func formatBool(value bool) string { if value { return "true" diff --git a/lib/server/admin_settings.go b/lib/server/admin_settings.go index 3966b98..77e81fc 100644 --- a/lib/server/admin_settings.go +++ b/lib/server/admin_settings.go @@ -84,8 +84,19 @@ func (app *App) handleAdminSettingsSave(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"}) return } + currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"}) + return + } + if currentOverrides == nil { + currentOverrides = map[string]string{} + } + for key, value := range request.Values { + currentOverrides[key] = value + } - rows, warnings, err := app.applySettingsOverrideSet(request.Values) + rows, warnings, err := app.applySettingsOverrideSet(currentOverrides) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -168,30 +179,24 @@ func (app *App) handleAdminSettingsReset(ctx *gin.Context) { var request adminSettingsResetRequest _ = ctx.ShouldBindJSON(&request) - defs := config.EditableDefinitions() - overrideSet := make(map[string]string, len(defs)) + overrideSet, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"}) + return + } + if overrideSet == nil { + overrideSet = map[string]string{} + } targetKeys := map[string]bool{} for _, key := range request.Keys { - targetKeys[key] = true + targetKeys[config.NormalizeLegacySettingKey(key)] = true } if len(targetKeys) == 0 { - for _, def := range defs { - overrideSet[def.Key] = app.config.DefaultValue(def.Key) - } + overrideSet = map[string]string{} } else { - currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath) - if err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"}) - return - } - for key, value := range currentOverrides { - overrideSet[key] = value - } - for _, def := range defs { - if targetKeys[def.Key] { - overrideSet[def.Key] = app.config.DefaultValue(def.Key) - } + for key := range targetKeys { + delete(overrideSet, key) } } @@ -203,7 +208,7 @@ func (app *App) handleAdminSettingsReset(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{ "ok": true, - "message": "Editable settings reset to application defaults", + "message": "Selected overrides cleared; environment and defaults now apply", "warnings": warnings, "rows": rows, }) @@ -231,7 +236,12 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti sort.Strings(keys) for _, key := range keys { - value := strings.TrimSpace(values[key]) + normalizedKey, normalizedValue, err := config.NormalizeOverrideInput(key, strings.TrimSpace(values[key])) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", key, err) + } + key = normalizedKey + value := normalizedValue def, ok := editable[key] if !ok { if _, found := config.Definition(key); found { @@ -447,10 +457,10 @@ func settingsDescription(key string) string { config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.", config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.", config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.", - config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling applied to future requests across the whole app.", - config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling applied to future requests across the whole app.", - config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling used by future account-aware flows.", - config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling used by future account-aware flows.", + config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling in GB applied to future requests across the whole app. Decimal values allowed.", + config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling in GB applied to future requests across the whole app. Decimal values allowed.", + config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling in GB used by future account-aware flows. Decimal values allowed.", + config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling in GB used by future account-aware flows. Decimal values allowed.", config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.", config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.", config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.", diff --git a/lib/server/admin_settings_test.go b/lib/server/admin_settings_test.go index a8c2ec7..7000880 100644 --- a/lib/server/admin_settings_test.go +++ b/lib/server/admin_settings_test.go @@ -82,7 +82,7 @@ func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) { func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) { app, router := setupAdminSettingsTest(t) - request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000"}}`)) + request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000","global_max_file_size_gb":"0.5"}}`)) request.Header.Set("Content-Type", "application/json") request.AddCookie(authCookie(app)) response := httptest.NewRecorder() @@ -97,6 +97,9 @@ func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) { if app.config.BoxPollIntervalMS != 6000 { t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS) } + if app.config.GlobalMaxFileSizeBytes != 512*1024*1024 { + t.Fatalf("expected size override in bytes, got %d", app.config.GlobalMaxFileSizeBytes) + } overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath) if err != nil { @@ -105,6 +108,12 @@ func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) { if overrides[config.SettingAPIEnabled] != "true" { t.Fatalf("expected persisted API override, got %#v", overrides) } + if _, exists := overrides[config.SettingBoxPollIntervalMS]; !exists { + t.Fatalf("expected changed poll interval override to be persisted, got %#v", overrides) + } + if _, exists := overrides[config.SettingSessionTTLSeconds]; exists { + t.Fatalf("expected untouched setting to stay out of overrides, got %#v", overrides) + } } func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) { @@ -160,8 +169,8 @@ func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) { if response.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String()) } - if !app.config.APIEnabled { - t.Fatal("expected reset to built-in defaults to restore APIEnabled=true") + if app.config.APIEnabled { + t.Fatal("expected reset to respect environment and restore APIEnabled=false") } } @@ -240,12 +249,16 @@ func clearAdminSettingsEnv(t *testing.T) { "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", + "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", + "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", + "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", + "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_SESSION_TTL_SECONDS", diff --git a/run.sh b/run.sh index 5aede2f..dbc2fe8 100755 --- a/run.sh +++ b/run.sh @@ -15,9 +15,9 @@ export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXP export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}" # Storage and expiry limits used by the upload UI and backend validators. -# Use megabytes here; WarpBox converts these to bytes internally. -export WARPBOX_GLOBAL_MAX_FILE_SIZE_MB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_MB:-2048}" # 2 GiB -export WARPBOX_GLOBAL_MAX_BOX_SIZE_MB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_MB:-4096}" # 4 GiB +# Use decimal gigabytes here. Examples: 2, 4, 0.5 +export WARPBOX_GLOBAL_MAX_FILE_SIZE_GB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_GB:-2}" # 2 GB +export WARPBOX_GLOBAL_MAX_BOX_SIZE_GB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_GB:-4}" # 4 GB export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours diff --git a/static/css/settings.css b/static/css/settings.css index 4d6feab..a26e665 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -398,6 +398,12 @@ gap: 4px; } +.setting-input-row { + display: flex; + align-items: center; + gap: 6px; +} + .setting-hint { color: #444444; font-size: 11px; diff --git a/static/js/admin/settings.js b/static/js/admin/settings.js index c136a01..3701acb 100644 --- a/static/js/admin/settings.js +++ b/static/js/admin/settings.js @@ -82,7 +82,10 @@ const value = currentValue(row); let valid = true; - if (row.type === "int" || row.type === "int64") { + if (row.type === "size_gb") { + if (!/^\d+(?:\.\d+)?$/.test(value)) valid = false; + else if (Number(value) < row.minimum) valid = false; + } else if (row.type === "int" || row.type === "int64") { if (!/^\d+$/.test(value)) valid = false; else if (Number(value) < row.minimum) valid = false; } else if (row.type === "bool") { @@ -179,7 +182,7 @@ function draftValues() { const values = {}; rows.forEach((row) => { - if (!row.locked) values[row.key] = currentValue(row); + if (!row.locked && isDirty(row)) values[row.key] = currentValue(row); }); return values; } @@ -205,6 +208,8 @@ if (row.hint) { row.hint.textContent = payload.locked ? "Locked by environment or hard runtime implication." + : payload.type === "size_gb" + ? "Use GB values. Decimals allowed, for example `0.5`." : (payload.default_value ? `Default: ${payload.default_value}` : ""); } if (row.badge) { @@ -241,8 +246,13 @@ } async function saveChanges() { + const values = draftValues(); + if (Object.keys(values).length === 0) { + showToast("No changed settings to save", "info"); + return; + } try { - const payload = await postJSON("/admin/settings/save", { values: draftValues() }); + const payload = await postJSON("/admin/settings/save", { values }); hydrateRows(payload.rows); showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success"); } catch (error) { @@ -261,6 +271,25 @@ } } + async function resetSingleSetting(row) { + if (row.locked || !row.input) return; + + if (isDirty(row)) { + row.input.value = row.element.dataset.original || ""; + updateView(); + showToast(`${row.label} draft cleared`); + return; + } + + try { + const payload = await postJSON("/admin/settings/reset", { keys: [row.key] }); + hydrateRows(payload.rows); + showToast(`${row.label} reset`, "success"); + } catch (error) { + showToast(error.message, "error", 3200); + } + } + async function exportSettings() { try { const response = await fetch("/admin/settings/export"); @@ -321,8 +350,8 @@ window.WarpBoxUI?.openPopup?.( "Reset Behavior", ` -
Reset defaults writes built-in WarpBox defaults as admin overrides for editable settings.
-Environment-only settings stay locked and unchanged.
+Reset clears saved admin overrides.
+After reset, environment values win again. If no environment value exists, built-in defaults apply.
` ); } @@ -390,11 +419,7 @@ rows.forEach((row) => { row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView); - row.element.querySelector(".row-reset")?.addEventListener("click", () => { - if (row.locked || !row.input) return; - row.input.value = row.element.dataset.default || row.element.dataset.original || ""; - updateView(); - }); + row.element.querySelector(".row-reset")?.addEventListener("click", () => resetSingleSetting(row)); row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row)); }); diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 1b1a60f..4b72fd4 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -189,9 +189,12 @@ {{ else }} - +