package server import ( "fmt" "net/http" "sort" "strings" "time" "github.com/gin-gonic/gin" "warpbox/lib/config" ) type adminSettingsCategoryView struct { Key string Label string Icon string Count int Rows []adminSettingRowView } type adminSettingRowView struct { Key string `json:"key"` Label string `json:"label"` EnvName string `json:"env_name"` Category string `json:"category"` CategoryLabel string `json:"category_label"` Type string `json:"type"` Value string `json:"value"` DefaultValue string `json:"default_value"` Source string `json:"source"` SourceBadge string `json:"source_badge"` Editable bool `json:"editable"` Locked bool `json:"locked"` HardLimit bool `json:"hard_limit"` Minimum int64 `json:"minimum"` Description string `json:"description"` } type adminSettingsSaveRequest struct { Values map[string]string `json:"values"` } type adminSettingsImportRequest struct { Settings map[string]string `json:"settings"` EditableSettings map[string]string `json:"editable_settings"` Values map[string]string `json:"values"` Changes map[string]string `json:"changes"` } type adminSettingsResetRequest struct { Keys []string `json:"keys"` } type adminSettingsExportResponse struct { Format string `json:"format"` ExportedAt string `json:"exported_at"` Settings map[string]string `json:"settings"` EditableSettings map[string]string `json:"editable_settings"` Rows []adminSettingRowView `json:"rows"` } func (app *App) handleAdminSettings(ctx *gin.Context) { rows, categories := app.buildAdminSettingsRows() ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{ "AdminUsername": app.config.AdminUsername, "AdminEmail": app.config.AdminEmail, "ActivePage": "settings", "Rows": rows, "Categories": categories, "RowsJSON": rows, }) } func (app *App) handleAdminSettingsExport(ctx *gin.Context) { rows, _ := app.buildAdminSettingsRows() ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows)) } func (app *App) handleAdminSettingsSave(ctx *gin.Context) { var request adminSettingsSaveRequest if err := ctx.ShouldBindJSON(&request); err != nil { 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(currentOverrides) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, gin.H{ "ok": true, "message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)), "warnings": warnings, "rows": rows, }) } func (app *App) handleAdminSettingsImport(ctx *gin.Context) { var request adminSettingsImportRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"}) return } values := request.Values if len(values) == 0 { values = request.Settings } if len(values) == 0 { values = request.EditableSettings } if len(values) == 0 { values = request.Changes } if len(values) == 0 { ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"}) return } editable := map[string]bool{} for _, def := range config.EditableDefinitions() { editable[def.Key] = true } filtered := make(map[string]string, len(values)) warnings := make([]string, 0) for key, value := range values { if editable[key] { filtered[key] = value continue } if _, found := config.Definition(key); found { warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key)) continue } warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key)) } currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"}) return } for key, value := range currentOverrides { if _, exists := filtered[key]; !exists { filtered[key] = value } } rows, applyWarnings, err := app.applySettingsOverrideSet(filtered) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } warnings = append(warnings, applyWarnings...) ctx.JSON(http.StatusOK, gin.H{ "ok": true, "message": fmt.Sprintf("Imported %d setting value(s)", len(values)), "warnings": warnings, "rows": rows, }) } func (app *App) handleAdminSettingsReset(ctx *gin.Context) { var request adminSettingsResetRequest _ = ctx.ShouldBindJSON(&request) 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[config.NormalizeLegacySettingKey(key)] = true } if len(targetKeys) == 0 { overrideSet = map[string]string{} } else { for key := range targetKeys { delete(overrideSet, key) } } rows, warnings, err := app.applySettingsOverrideSet(overrideSet) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, gin.H{ "ok": true, "message": "Selected overrides cleared; environment and defaults now apply", "warnings": warnings, "rows": rows, }) } func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) { if !app.config.AllowAdminSettingsOverride { return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment") } if values == nil { values = map[string]string{} } overrideSet := make(map[string]string, len(values)) warnings := make([]string, 0) editable := map[string]config.SettingDefinition{} for _, def := range config.EditableDefinitions() { editable[def.Key] = def } keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { 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 { return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key) } warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key)) continue } if value == "" && def.Type != config.SettingTypeText { return nil, nil, fmt.Errorf("setting %q cannot be blank", key) } overrideSet[key] = value } nextCfg, err := config.Load() if err != nil { return nil, nil, err } if err := nextCfg.ApplyOverrides(overrideSet); err != nil { return nil, nil, err } if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil { return nil, nil, err } app.config = nextCfg applyBoxstoreRuntimeConfig(app.config) app.reloadSecurityConfig() rows, _ := app.buildAdminSettingsRows() return rows, warnings, nil } func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse { settings := make(map[string]string, len(rows)) editable := make(map[string]string) for _, row := range rows { settings[row.Key] = row.Value if row.Editable && !row.Locked { editable[row.Key] = row.Value } } return adminSettingsExportResponse{ Format: "warpbox.settings.export.v1", ExportedAt: time.Now().UTC().Format(time.RFC3339), Settings: settings, EditableSettings: editable, Rows: rows, } } func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) { cfgRows := app.config.SettingRows() rows := make([]adminSettingRowView, 0, len(cfgRows)+5) for _, row := range cfgRows { rows = append(rows, app.makeDefinitionSettingRow(row)) } rows = append(rows, app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."), app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."), app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."), app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."), app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."), ) sort.Slice(rows, func(i, j int) bool { if rows[i].Category == rows[j].Category { return rows[i].Label < rows[j].Label } return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category) }) categoryMeta := settingsCategoryMeta() categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1) allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)} categories = append(categories, allCategory) grouped := map[string][]adminSettingRowView{} for _, row := range rows { grouped[row.Category] = append(grouped[row.Category], row) } for _, meta := range categoryMeta { categories = append(categories, adminSettingsCategoryView{ Key: meta.Key, Label: meta.Label, Icon: meta.Icon, Count: len(grouped[meta.Key]), Rows: grouped[meta.Key], }) } return rows, categories } func boolString(value bool) string { if value { return "true" } return "false" } func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView { def := row.Definition locked := !def.Editable || def.HardLimit source := string(row.Source) sourceBadge := source if locked { sourceBadge = "hard env" } return adminSettingRowView{ Key: def.Key, Label: def.Label, EnvName: def.EnvName, Category: settingsCategoryForKey(def.Key), CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)), Type: string(def.Type), Value: row.Value, DefaultValue: app.config.DefaultValue(def.Key), Source: source, SourceBadge: sourceBadge, Editable: def.Editable && !def.HardLimit, Locked: locked, HardLimit: def.HardLimit, Minimum: def.Minimum, Description: settingsDescription(def.Key), } } func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView { return adminSettingRowView{ Key: key, Label: label, EnvName: envName, Category: category, CategoryLabel: settingsCategoryLabel(category), Type: rowType, Value: value, DefaultValue: "", Source: "environment", SourceBadge: "hard env", Editable: false, Locked: true, HardLimit: true, Description: description, } } type settingsCategoryInfo struct { Key string Label string Icon string } func settingsCategoryMeta() []settingsCategoryInfo { return []settingsCategoryInfo{ {Key: "uploads", Label: "Uploads", Icon: "↥"}, {Key: "downloads", Label: "Downloads", Icon: "↧"}, {Key: "retention", Label: "Retention", Icon: "⌛"}, {Key: "security", Label: "Security", Icon: "🔒"}, {Key: "activity", Label: "Activity", Icon: "☰"}, {Key: "accounts", Label: "Accounts", Icon: "☺"}, {Key: "api", Label: "API", Icon: "{ }"}, {Key: "storage", Label: "Storage", Icon: "▥"}, {Key: "workers", Label: "Workers", Icon: "⚙"}, } } func settingsCategoryLabel(key string) string { for _, meta := range settingsCategoryMeta() { if meta.Key == key { return meta.Label } } return "General" } func settingsCategoryRank(key string) int { for index, meta := range settingsCategoryMeta() { if meta.Key == key { return index } } return len(settingsCategoryMeta()) + 1 } func settingsCategoryForKey(key string) string { switch key { case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes: return "uploads" case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB: return "uploads" case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled: return "downloads" case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail: return "retention" case config.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts: return "security" case config.SettingActivityRetentionSeconds: return "activity" case config.SettingSessionTTLSeconds: return "accounts" case config.SettingAPIEnabled: return "api" case config.SettingDataDir: return "storage" case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds: return "workers" default: return "accounts" } } func settingsDescription(key string) string { descriptions := map[string]string{ config.SettingGuestUploadsEnabled: "Allow unauthenticated guests to create boxes through the public upload flow.", config.SettingAPIEnabled: "Enable API endpoints used by the browser upload and status workflows.", config.SettingZipDownloadsEnabled: "Allow archive downloads for full boxes when ZIP is supported.", config.SettingOneTimeDownloadsEnabled: "Enable one-time download retention mode for boxes.", config.SettingOneTimeDownloadExpirySecs: "Expiry window, in seconds, for one-time download boxes after upload completion.", config.SettingOneTimeDownloadRetryFail: "When enabled by environment, failed one-time ZIP writes leave the box retryable.", config.SettingRenewOnAccessEnabled: "Extend retention when a box page is viewed.", 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 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.", config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.", config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.", config.SettingActivityRetentionSeconds: "How long activity events stay stored before automatic prune.", config.SettingSecurityIPWhitelist: "Comma-separated IPs that bypass generic security bans and rate-limits.", config.SettingSecurityAdminIPWhitelist: "Comma-separated IPs allowed to bypass admin login brute-force controls.", config.SettingSecurityLoginWindowSecs: "Window used for failed admin login counting.", config.SettingSecurityLoginMaxAttempts: "Max failed admin logins per window before temporary ban.", config.SettingSecurityBanSeconds: "Duration for automatic temporary IP bans.", config.SettingSecurityScanWindowSecs: "Window used for malicious path scan detection.", config.SettingSecurityScanMaxAttempts: "Max suspicious path probes per window before temporary ban.", config.SettingSecurityUploadWindowSecs: "Window used for per-IP upload throttling.", config.SettingSecurityUploadMaxRequests: "Max upload requests per IP per upload window.", config.SettingSecurityUploadMaxGB: "Max upload volume in GB per IP per upload window.", } return descriptions[key] }