Implements a master toggle for security features across config, CLI, and application logic. This allows granular control over whether the advanced security middleware and protections are active globally.
495 lines
18 KiB
Go
495 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)
|
|
if err := app.reloadSecurityConfig(); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
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.SettingSecurityEnabled, 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.SettingSecurityEnabled: "Master switch for security middleware, automated bans, suspicious path detection, and upload throttling.",
|
|
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]
|
|
}
|