492 lines
18 KiB
Go
492 lines
18 KiB
Go
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]
|
|
}
|