refactor(storage): standardize size limits to use GB units
This commit is contained in:
@@ -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 \
|
||||
|
||||
14
README.md
14
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
10
docs/tech.md
10
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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
6
run.sh
6
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
|
||||
|
||||
|
||||
@@ -398,6 +398,12 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.setting-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
color: #444444;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -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",
|
||||
`
|
||||
<p>Reset defaults writes built-in WarpBox defaults as admin overrides for editable settings.</p>
|
||||
<p>Environment-only settings stay locked and unchanged.</p>
|
||||
<p>Reset clears saved admin overrides.</p>
|
||||
<p>After reset, environment values win again. If no environment value exists, built-in defaults apply.</p>
|
||||
`
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -189,9 +189,12 @@
|
||||
<option value="false"{{ if eq .Value "false" }} selected{{ end }}>false</option>
|
||||
</select>
|
||||
{{ else }}
|
||||
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
|
||||
<div class="setting-input-row">
|
||||
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
|
||||
{{ if eq .Type "size_gb" }}<span class="settings-badge badge-default">GB</span>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="setting-hint" data-role="hint">{{ if .Locked }}Locked by environment or hard runtime implication.{{ else if .DefaultValue }}Default: {{ .DefaultValue }}{{ end }}</div>
|
||||
<div class="setting-hint" data-role="hint">{{ if .Locked }}Locked by environment or hard runtime implication.{{ else if eq .Type "size_gb" }}Use GB values. Decimals allowed, for example `0.5`.{{ else if .DefaultValue }}Default: {{ .DefaultValue }}{{ end }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="setting-actions">
|
||||
|
||||
Reference in New Issue
Block a user