package server import ( "encoding/json" "fmt" "net/http" "sort" "strings" "time" "github.com/gin-gonic/gin" "warpbox/lib/config" "warpbox/lib/metastore" ) type SettingsView struct { PageTitle string WindowTitle string WindowIcon string PageScripts []string AccountNav AccountNavView CSRFToken string Groups []SettingsGroupView OverridesAllowed bool CanEdit bool Error string Notice string } type SettingsGroupView struct { Key string Label string Description string Rows []SettingsRowView } type SettingsRowView struct { Key string Label string Description string Type config.SettingType Value string DisplayValue string Source string EnvName string Editable bool LockedReason string Future bool } type SettingsBackup struct { Version int `json:"version"` ExportedAt string `json:"exported_at"` Settings map[string]string `json:"settings"` Metadata map[string]string `json:"metadata,omitempty"` } type ImportResult struct { Applied int `json:"applied"` Keys []string `json:"keys"` } type settingsMeta struct { Group string Description string Units string Future bool } var settingsGroups = []SettingsGroupView{ {Key: "uploads", Label: "Uploads", Description: "Guest uploads and upload size defaults."}, {Key: "downloads", Label: "Downloads", Description: "ZIP and one-time download behavior."}, {Key: "retention", Label: "Retention", Description: "Expiry and renewal defaults."}, {Key: "accounts", Label: "Accounts", Description: "Session and account defaults."}, {Key: "api", Label: "API", Description: "API surface toggles."}, {Key: "storage", Label: "Storage", Description: "Storage paths and hard capacity limits."}, {Key: "workers", Label: "Workers", Description: "Background worker timing."}, {Key: "box_policy", Label: "Box policy", Description: "Defaults for future owner-managed boxes."}, } var settingsMetadata = map[string]settingsMeta{ config.SettingGuestUploadsEnabled: {Group: "uploads", Description: "Allow guests to create upload boxes."}, config.SettingDefaultUserMaxFileBytes: {Group: "uploads", Description: "Default per-user file size limit. Zero means unlimited.", Units: "bytes"}, config.SettingDefaultUserMaxBoxBytes: {Group: "uploads", Description: "Default per-user total box size limit. Zero means unlimited.", Units: "bytes"}, config.SettingZipDownloadsEnabled: {Group: "downloads", Description: "Allow ZIP downloads when a box permits it."}, config.SettingOneTimeDownloadsEnabled: {Group: "downloads", Description: "Allow one-time ZIP handoff boxes."}, config.SettingOneTimeDownloadExpirySecs: {Group: "downloads", Description: "How long one-time downloads stay retryable or pending.", Units: "duration"}, config.SettingOneTimeDownloadRetryFail: {Group: "downloads", Description: "Keep one-time boxes retryable after a ZIP writer failure."}, config.SettingDefaultGuestExpirySecs: {Group: "retention", Description: "Default guest box expiry.", Units: "duration"}, config.SettingMaxGuestExpirySecs: {Group: "retention", Description: "Maximum guest box expiry.", Units: "duration"}, config.SettingRenewOnAccessEnabled: {Group: "retention", Description: "Allow expiry renewal when a box is opened."}, config.SettingRenewOnDownloadEnabled: {Group: "retention", Description: "Allow expiry renewal when files are downloaded."}, config.SettingSessionTTLSeconds: {Group: "accounts", Description: "Account session lifetime.", Units: "duration"}, config.SettingAPIEnabled: {Group: "api", Description: "Expose API-style upload/status endpoints."}, config.SettingDataDir: {Group: "storage", Description: "Base data directory. Environment only."}, config.SettingGlobalMaxFileSizeBytes: {Group: "storage", Description: "Hard global file size cap. Environment only.", Units: "bytes"}, config.SettingGlobalMaxBoxSizeBytes: {Group: "storage", Description: "Hard global box size cap. Environment only.", Units: "bytes"}, config.SettingBoxPollIntervalMS: {Group: "workers", Description: "Browser polling cadence for box status.", Units: "milliseconds"}, config.SettingThumbnailBatchSize: {Group: "workers", Description: "Thumbnail worker batch size."}, config.SettingThumbnailIntervalSeconds: {Group: "workers", Description: "Thumbnail worker interval.", Units: "duration"}, config.SettingBoxOwnerEditEnabled: {Group: "box_policy", Description: "Default: owners may edit their boxes."}, config.SettingBoxOwnerRefreshEnabled: {Group: "box_policy", Description: "Default: owners may refresh box expiry."}, config.SettingBoxOwnerMaxRefreshCount: {Group: "box_policy", Description: "Default maximum number of owner refreshes."}, config.SettingBoxOwnerMaxRefreshAmount: {Group: "box_policy", Description: "Default maximum expiry added per owner refresh.", Units: "duration"}, config.SettingBoxOwnerMaxTotalExpiry: {Group: "box_policy", Description: "Default maximum total box expiry for owner-managed boxes.", Units: "duration"}, config.SettingBoxOwnerPasswordEdit: {Group: "box_policy", Description: "Default: owners may edit box passwords."}, } func (app *App) handleAccountSettings(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } view, err := app.ListSettings(ctx, actor) if err != nil { ctx.String(http.StatusForbidden, "Permission denied") return } ctx.HTML(http.StatusOK, "account_settings.html", view) } func (app *App) handleAccountSettingsPost(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := ctx.Request.ParseForm(); err != nil { app.renderSettingsWithMessage(ctx, actor, "could not parse settings form", "") return } editable := map[string]config.SettingDefinition{} for _, def := range config.EditableDefinitions() { editable[def.Key] = def } for key := range ctx.Request.PostForm { if key == "csrf_token" { continue } if _, ok := editable[key]; ok { continue } if _, ok := config.Definition(key); ok { app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("setting %q is locked", key), "") return } app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("unknown setting %q", key), "") return } changes := map[string]string{} for _, def := range editable { if def.Type == config.SettingTypeBool { value := "false" if ctx.PostForm(def.Key) == "true" { value = "true" } changes[def.Key] = value continue } if _, exists := ctx.GetPostForm(def.Key); exists { changes[def.Key] = ctx.PostForm(def.Key) } } if err := app.UpdateSettings(ctx, actor, changes); err != nil { app.renderSettingsWithMessage(ctx, actor, err.Error(), "") return } ctx.Redirect(http.StatusSeeOther, "/account/settings") } func (app *App) handleAccountSettingsReset(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if err := app.ResetSettingOverride(ctx, actor, ctx.PostForm("key")); err != nil { app.renderSettingsWithMessage(ctx, actor, err.Error(), "") return } ctx.Redirect(http.StatusSeeOther, "/account/settings") } func (app *App) handleAccountSettingsExport(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } backup, err := app.ExportSettings(ctx, actor) if err != nil { ctx.String(http.StatusForbidden, "Permission denied") return } ctx.Header("Content-Disposition", `attachment; filename="warpbox-settings.json"`) ctx.JSON(http.StatusOK, backup) } func (app *App) handleAccountSettingsImport(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } if !strings.HasPrefix(strings.ToLower(ctx.GetHeader("Content-Type")), "application/json") { ctx.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "settings import requires application/json"}) return } var backup SettingsBackup if err := json.NewDecoder(ctx.Request.Body).Decode(&backup); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings JSON"}) return } result, err := app.ImportSettings(ctx, actor, backup) if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ctx.JSON(http.StatusOK, result) } func (app *App) ListSettings(ctx *gin.Context, actor metastore.User) (SettingsView, error) { perms := currentAccountPermissions(ctx) if !perms.AdminSettingsManage { return SettingsView{}, fmt.Errorf("permission denied") } rows := app.settingsRows(perms.AdminSettingsManage && app.config.AllowAdminSettingsOverride) groups := make([]SettingsGroupView, 0, len(settingsGroups)) for _, group := range settingsGroups { copyGroup := group copyGroup.Rows = rows[group.Key] groups = append(groups, copyGroup) } return SettingsView{ PageTitle: "WarpBox Settings", WindowTitle: "WarpBox Account Settings", WindowIcon: "S", PageScripts: []string{"/static/js/account-settings.js"}, AccountNav: app.accountNavView(ctx, "settings"), CSRFToken: app.currentCSRFToken(ctx), Groups: groups, OverridesAllowed: app.config.AllowAdminSettingsOverride, CanEdit: app.config.AllowAdminSettingsOverride, }, nil } func (app *App) UpdateSettings(ctx *gin.Context, actor metastore.User, changes map[string]string) error { if err := app.requireSettingsEdit(ctx); err != nil { return err } if !app.config.AllowAdminSettingsOverride { return fmt.Errorf("admin settings overrides are disabled") } if err := validateSettingChanges(changes); err != nil { return err } for key, value := range changes { if err := app.store.SetSetting(key, value); err != nil { return err } } return app.reloadRuntimeConfig() } func (app *App) ResetSettingOverride(ctx *gin.Context, actor metastore.User, key string) error { if err := app.requireSettingsEdit(ctx); err != nil { return err } def, ok := config.Definition(strings.TrimSpace(key)) if !ok { return fmt.Errorf("unknown setting %q", key) } if !def.Editable || def.HardLimit { return fmt.Errorf("setting %q cannot be reset from account settings", key) } if err := app.store.DeleteSetting(def.Key); err != nil { return err } return app.reloadRuntimeConfig() } func (app *App) ExportSettings(ctx *gin.Context, actor metastore.User) (SettingsBackup, error) { perms := currentAccountPermissions(ctx) if !perms.AdminSettingsManage { return SettingsBackup{}, fmt.Errorf("permission denied") } settings := map[string]string{} for _, def := range config.EditableDefinitions() { settings[def.Key] = app.config.SettingValue(def.Key) } return SettingsBackup{ Version: 1, ExportedAt: time.Now().UTC().Format(time.RFC3339), Settings: settings, Metadata: map[string]string{ "app": "WarpBox", }, }, nil } func (app *App) ImportSettings(ctx *gin.Context, actor metastore.User, backup SettingsBackup) (ImportResult, error) { if err := app.requireSettingsEdit(ctx); err != nil { return ImportResult{}, err } if !app.config.AllowAdminSettingsOverride { return ImportResult{}, fmt.Errorf("admin settings overrides are disabled") } if backup.Settings == nil { return ImportResult{}, fmt.Errorf("settings backup has no settings") } if err := validateSettingChanges(backup.Settings); err != nil { return ImportResult{}, err } keys := make([]string, 0, len(backup.Settings)) for key := range backup.Settings { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if err := app.store.SetSetting(key, backup.Settings[key]); err != nil { return ImportResult{}, err } } if err := app.reloadRuntimeConfig(); err != nil { return ImportResult{}, err } return ImportResult{Applied: len(keys), Keys: keys}, nil } func (app *App) renderSettingsWithMessage(ctx *gin.Context, actor metastore.User, errorMessage string, notice string) { view, err := app.ListSettings(ctx, actor) if err != nil { ctx.String(http.StatusForbidden, "Permission denied") return } view.Error = errorMessage view.Notice = notice ctx.HTML(http.StatusOK, "account_settings.html", view) } func (app *App) requireSettingsEdit(ctx *gin.Context) error { perms := currentAccountPermissions(ctx) if !perms.AdminSettingsManage { return fmt.Errorf("permission denied") } return nil } func (app *App) settingsRows(canEdit bool) map[string][]SettingsRowView { out := map[string][]SettingsRowView{} for _, row := range app.config.SettingRows() { meta := settingsMetadata[row.Definition.Key] group := meta.Group if group == "" { group = "accounts" } editable := canEdit && row.Definition.Editable && !row.Definition.HardLimit out[group] = append(out[group], SettingsRowView{ Key: row.Definition.Key, Label: row.Definition.Label, Description: meta.Description, Type: row.Definition.Type, Value: row.Value, DisplayValue: settingDisplayValue(row.Value, meta.Units), Source: settingSourceLabel(row.Source, row.Definition), EnvName: row.Definition.EnvName, Editable: editable, LockedReason: settingLockedReason(row.Definition, canEdit), Future: meta.Future, }) } return out } func validateSettingChanges(changes map[string]string) error { if len(changes) == 0 { return fmt.Errorf("no settings provided") } cfg, err := config.Load() if err != nil { return err } for key, value := range changes { if _, ok := config.Definition(key); !ok { return fmt.Errorf("unknown setting %q", key) } if err := cfg.ApplyOverride(key, value); err != nil { return err } } return nil } func (app *App) reloadRuntimeConfig() error { cfg, err := config.Load() if err != nil { return err } overrides, err := app.store.ListSettings() if err != nil { return err } if err := cfg.ApplyOverrides(overrides); err != nil { return err } app.config = cfg applyBoxstoreRuntimeConfig(cfg) return nil } func settingSourceLabel(source config.Source, def config.SettingDefinition) string { if def.HardLimit { return "hard env" } if !def.Editable { return "locked" } switch source { case config.SourceDB: return "override" case config.SourceEnv: return "env" default: return "default" } } func settingLockedReason(def config.SettingDefinition, canEdit bool) string { if !canEdit { return "settings changes disabled" } if def.HardLimit { return "hard environment limit" } if !def.Editable { return "runtime editing not supported" } return "" } func settingDisplayValue(value string, units string) string { switch units { case "bytes": parsed, ok := parseInt64String(value) if !ok { return value } if parsed == 0 { return "unlimited" } return fmt.Sprintf("%s (%s bytes)", formatBytesForSettings(parsed), value) case "duration": parsed, ok := parseInt64String(value) if !ok { return value } return fmt.Sprintf("%s (%s seconds)", formatDurationForSettings(parsed), value) case "milliseconds": return value + " ms" default: return value } } func parseInt64String(value string) (int64, bool) { var parsed int64 if _, err := fmt.Sscan(strings.TrimSpace(value), &parsed); err != nil { return 0, false } return parsed, true } func formatBytesForSettings(value int64) string { units := []string{"B", "KiB", "MiB", "GiB", "TiB"} size := float64(value) unit := 0 for size >= 1024 && unit < len(units)-1 { size /= 1024 unit++ } return fmt.Sprintf("%.1f %s", size, units[unit]) } func formatDurationForSettings(seconds int64) string { switch { case seconds == 0: return "none" case seconds%86400 == 0: return fmt.Sprintf("%d days", seconds/86400) case seconds%3600 == 0: return fmt.Sprintf("%d hours", seconds/3600) case seconds%60 == 0: return fmt.Sprintf("%d minutes", seconds/60) default: return fmt.Sprintf("%d seconds", seconds) } }