feat(setting): Implemented the settings administrative menu
This commit is contained in:
461
lib/server/admin_settings.go
Normal file
461
lib/server/admin_settings.go
Normal file
@@ -0,0 +1,461 @@
|
||||
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
|
||||
}
|
||||
|
||||
rows, warnings, err := app.applySettingsOverrideSet(request.Values)
|
||||
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)
|
||||
|
||||
defs := config.EditableDefinitions()
|
||||
overrideSet := make(map[string]string, len(defs))
|
||||
targetKeys := map[string]bool{}
|
||||
for _, key := range request.Keys {
|
||||
targetKeys[key] = true
|
||||
}
|
||||
|
||||
if len(targetKeys) == 0 {
|
||||
for _, def := range defs {
|
||||
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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": "Editable settings reset to application defaults",
|
||||
"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 {
|
||||
value := strings.TrimSpace(values[key])
|
||||
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)
|
||||
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: "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.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||
return "downloads"
|
||||
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||
return "retention"
|
||||
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 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.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.",
|
||||
}
|
||||
return descriptions[key]
|
||||
}
|
||||
Reference in New Issue
Block a user