feat(setting): Implemented the settings administrative menu

This commit is contained in:
2026-05-01 01:51:06 +03:00
parent 36d49a970e
commit d0aa86205f
20 changed files with 3759 additions and 42 deletions

View 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]
}