2026-05-01 01:51:06 +03:00
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
}
2026-05-01 02:14:05 +03:00
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
}
2026-05-01 01:51:06 +03:00
2026-05-01 02:14:05 +03:00
rows , warnings , err := app . applySettingsOverrideSet ( currentOverrides )
2026-05-01 01:51:06 +03:00
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 )
2026-05-01 02:14:05 +03:00
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 { }
}
2026-05-01 01:51:06 +03:00
targetKeys := map [ string ] bool { }
for _ , key := range request . Keys {
2026-05-01 02:14:05 +03:00
targetKeys [ config . NormalizeLegacySettingKey ( key ) ] = true
2026-05-01 01:51:06 +03:00
}
if len ( targetKeys ) == 0 {
2026-05-01 02:14:05 +03:00
overrideSet = map [ string ] string { }
2026-05-01 01:51:06 +03:00
} else {
2026-05-01 02:14:05 +03:00
for key := range targetKeys {
delete ( overrideSet , key )
2026-05-01 01:51:06 +03:00
}
}
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 ,
2026-05-01 02:14:05 +03:00
"message" : "Selected overrides cleared; environment and defaults now apply" ,
2026-05-01 01:51:06 +03:00
"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 {
2026-05-01 02:14:05 +03:00
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
2026-05-01 01:51:06 +03:00
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 )
2026-05-01 13:10:23 +03:00
app . reloadSecurityConfig ( )
2026-05-01 01:51:06 +03:00
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 : "⌛" } ,
2026-05-01 13:10:23 +03:00
{ Key : "security" , Label : "Security" , Icon : "🔒" } ,
{ Key : "activity" , Label : "Activity" , Icon : "☰" } ,
2026-05-01 01:51:06 +03:00
{ 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"
2026-05-01 13:10:23 +03:00
case config . SettingSecurityUploadWindowSecs , config . SettingSecurityUploadMaxRequests , config . SettingSecurityUploadMaxGB :
return "uploads"
2026-05-01 01:51:06 +03:00
case config . SettingZipDownloadsEnabled , config . SettingOneTimeDownloadsEnabled , config . SettingOneTimeDownloadExpirySecs , config . SettingRenewOnDownloadEnabled :
return "downloads"
case config . SettingRenewOnAccessEnabled , config . SettingDefaultGuestExpirySecs , config . SettingMaxGuestExpirySecs , config . SettingOneTimeDownloadRetryFail :
return "retention"
2026-05-01 13:10:23 +03:00
case config . SettingSecurityIPWhitelist , config . SettingSecurityAdminIPWhitelist , config . SettingSecurityLoginWindowSecs , config . SettingSecurityLoginMaxAttempts , config . SettingSecurityBanSeconds , config . SettingSecurityScanWindowSecs , config . SettingSecurityScanMaxAttempts :
return "security"
case config . SettingActivityRetentionSeconds :
return "activity"
2026-05-01 01:51:06 +03:00
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." ,
2026-05-01 02:14:05 +03:00
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." ,
2026-05-01 01:51:06 +03:00
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." ,
2026-05-01 13:10:23 +03:00
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." ,
2026-05-01 01:51:06 +03:00
}
return descriptions [ key ]
}