feat(config): add box owner policy settings

Adds configuration options and environment variables to manage
box owner policies, including settings for refresh counts and expiry.
This commit is contained in:
2026-04-30 19:30:13 +03:00
parent b8bb75f7e0
commit 2714907ff4
22 changed files with 3694 additions and 37 deletions

View File

@@ -151,11 +151,6 @@ func buildAllEnvRows(includeHidden bool) []envRow {
} }
extra := buildExtraEnvRows(includeHidden) extra := buildExtraEnvRows(includeHidden)
if loadErr == nil {
for i := range extra {
extra[i].Default = extra[i].Default
}
}
rows = append(rows, extra...) rows = append(rows, extra...)
return rows return rows

View File

@@ -28,6 +28,12 @@ func TestDefaults(t *testing.T) {
if cfg.AdminPassword != "" { if cfg.AdminPassword != "" {
t.Fatal("expected default admin password to be empty") t.Fatal("expected default admin password to be empty")
} }
if !cfg.BoxOwnerEditEnabled || !cfg.BoxOwnerRefreshEnabled || !cfg.BoxOwnerPasswordEditEnabled {
t.Fatal("expected box owner policy defaults to be enabled")
}
if cfg.BoxOwnerMaxRefreshCount != 3 || cfg.BoxOwnerMaxRefreshAmountSeconds != 86400 || cfg.BoxOwnerMaxTotalExpirySeconds != 604800 {
t.Fatalf("unexpected box owner policy defaults: %#v", cfg)
}
} }
func TestEnvironmentOverrides(t *testing.T) { func TestEnvironmentOverrides(t *testing.T) {
@@ -39,6 +45,8 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000") t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root") t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true") t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
t.Setenv("WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", "5")
t.Setenv("WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", "false")
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
@@ -63,6 +71,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if !cfg.OneTimeDownloadRetryOnFailure { if !cfg.OneTimeDownloadRetryOnFailure {
t.Fatal("expected one-time retry-on-failure env override to be applied") t.Fatal("expected one-time retry-on-failure env override to be applied")
} }
if cfg.BoxOwnerMaxRefreshCount != 5 || cfg.BoxOwnerPasswordEditEnabled {
t.Fatal("expected box owner policy env overrides to be applied")
}
if cfg.Source(SettingAPIEnabled) != SourceEnv { if cfg.Source(SettingAPIEnabled) != SourceEnv {
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled)) t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
} }
@@ -148,6 +159,12 @@ func TestSettingsOverrideValidation(t *testing.T) {
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil { if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
t.Fatal("expected hard limit override to fail") t.Fatal("expected hard limit override to fail")
} }
if err := cfg.ApplyOverride(SettingBoxOwnerMaxRefreshCount, "2"); err != nil {
t.Fatalf("expected box owner policy override to pass: %v", err)
}
if cfg.BoxOwnerMaxRefreshCount != 2 {
t.Fatalf("expected box owner policy override to apply, got %d", cfg.BoxOwnerMaxRefreshCount)
}
} }
func clearConfigEnv(t *testing.T) { func clearConfigEnv(t *testing.T) {
@@ -181,6 +198,12 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS", "WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE", "WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS", "WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"WARPBOX_BOX_OWNER_EDIT_ENABLED",
"WARPBOX_BOX_OWNER_REFRESH_ENABLED",
"WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT",
"WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS",
"WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS",
"WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED",
} { } {
t.Setenv(name, "") t.Setenv(name, "")
} }

View File

@@ -20,6 +20,12 @@ var Definitions = []SettingDefinition{
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000}, {Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingBoxOwnerEditEnabled, EnvName: "WARPBOX_BOX_OWNER_EDIT_ENABLED", Label: "Box owner edit enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingBoxOwnerRefreshEnabled, EnvName: "WARPBOX_BOX_OWNER_REFRESH_ENABLED", Label: "Box owner refresh enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingBoxOwnerMaxRefreshCount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", Label: "Box owner max refresh count", Type: SettingTypeInt, Editable: true, Minimum: 0},
{Key: SettingBoxOwnerMaxRefreshAmount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", Label: "Box owner max refresh amount seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingBoxOwnerMaxTotalExpiry, EnvName: "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", Label: "Box owner max total expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingBoxOwnerPasswordEdit, EnvName: "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", Label: "Box owner password edit enabled", Type: SettingTypeBool, Editable: true},
} }
func (cfg *Config) SettingRows() []SettingRow { func (cfg *Config) SettingRows() []SettingRow {
@@ -38,6 +44,10 @@ func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key) return cfg.sourceFor(key)
} }
func (cfg *Config) SettingValue(key string) string {
return cfg.values[key]
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool { func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled { switch cfg.AdminEnabled {
case AdminEnabledFalse: case AdminEnabledFalse:

View File

@@ -11,24 +11,30 @@ import (
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
DataDir: "./data", DataDir: "./data",
AdminUsername: "admin", AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto, AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true, AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true, GuestUploadsEnabled: true,
APIEnabled: true, APIEnabled: true,
ZipDownloadsEnabled: true, ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true, OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60, OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false, OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10, DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60, MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60, SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000, BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10, ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30, ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source), BoxOwnerEditEnabled: true,
values: make(map[string]string), BoxOwnerRefreshEnabled: true,
BoxOwnerMaxRefreshCount: 3,
BoxOwnerMaxRefreshAmountSeconds: 24 * 60 * 60,
BoxOwnerMaxTotalExpirySeconds: 7 * 24 * 60 * 60,
BoxOwnerPasswordEditEnabled: true,
sources: make(map[string]Source),
values: make(map[string]string),
} }
// Config precedence: defaults -> env -> overrides. // Config precedence: defaults -> env -> overrides.
@@ -73,6 +79,9 @@ func Load() (*Config, error) {
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure}, {SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled}, {SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled}, {SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
{SettingBoxOwnerEditEnabled, "WARPBOX_BOX_OWNER_EDIT_ENABLED", &cfg.BoxOwnerEditEnabled},
{SettingBoxOwnerRefreshEnabled, "WARPBOX_BOX_OWNER_REFRESH_ENABLED", &cfg.BoxOwnerRefreshEnabled},
{SettingBoxOwnerPasswordEdit, "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", &cfg.BoxOwnerPasswordEditEnabled},
} }
for _, item := range envBools { for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil { if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
@@ -90,6 +99,8 @@ func Load() (*Config, error) {
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, {SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds}, {SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds}, {SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
{SettingBoxOwnerMaxRefreshAmount, "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", 0, &cfg.BoxOwnerMaxRefreshAmountSeconds},
{SettingBoxOwnerMaxTotalExpiry, "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", 0, &cfg.BoxOwnerMaxTotalExpirySeconds},
} }
for _, item := range envInt64s { for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil { if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
@@ -122,6 +133,7 @@ func Load() (*Config, error) {
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS}, {SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize}, {SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds}, {SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
} }
for _, item := range envInts { for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil { if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
@@ -171,6 +183,12 @@ func (cfg *Config) captureDefaults() {
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault) cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault) cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault) cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
cfg.setValue(SettingBoxOwnerEditEnabled, formatBool(cfg.BoxOwnerEditEnabled), SourceDefault)
cfg.setValue(SettingBoxOwnerRefreshEnabled, formatBool(cfg.BoxOwnerRefreshEnabled), SourceDefault)
cfg.setValue(SettingBoxOwnerMaxRefreshCount, strconv.Itoa(cfg.BoxOwnerMaxRefreshCount), SourceDefault)
cfg.setValue(SettingBoxOwnerMaxRefreshAmount, strconv.FormatInt(cfg.BoxOwnerMaxRefreshAmountSeconds, 10), SourceDefault)
cfg.setValue(SettingBoxOwnerMaxTotalExpiry, strconv.FormatInt(cfg.BoxOwnerMaxTotalExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingBoxOwnerPasswordEdit, formatBool(cfg.BoxOwnerPasswordEditEnabled), SourceDefault)
} }
func (cfg *Config) applyStringEnv(key string, name string, target *string) error { func (cfg *Config) applyStringEnv(key string, name string, target *string) error {

View File

@@ -36,6 +36,12 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size" SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir" SettingDataDir = "data_dir"
SettingBoxOwnerEditEnabled = "box_owner_edit_enabled"
SettingBoxOwnerRefreshEnabled = "box_owner_refresh_enabled"
SettingBoxOwnerMaxRefreshCount = "box_owner_max_refresh_count"
SettingBoxOwnerMaxRefreshAmount = "box_owner_max_refresh_amount_seconds"
SettingBoxOwnerMaxTotalExpiry = "box_owner_max_total_expiry_seconds"
SettingBoxOwnerPasswordEdit = "box_owner_password_edit_enabled"
) )
type SettingType string type SettingType string
@@ -84,16 +90,22 @@ type Config struct {
RenewOnAccessEnabled bool RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64 DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64 MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64 GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64 GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64 DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64 DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64 SessionTTLSeconds int64
BoxPollIntervalMS int BoxPollIntervalMS int
ThumbnailBatchSize int ThumbnailBatchSize int
ThumbnailIntervalSeconds int ThumbnailIntervalSeconds int
BoxOwnerEditEnabled bool
BoxOwnerRefreshEnabled bool
BoxOwnerMaxRefreshCount int
BoxOwnerMaxRefreshAmountSeconds int64
BoxOwnerMaxTotalExpirySeconds int64
BoxOwnerPasswordEditEnabled bool
sources map[string]Source sources map[string]Source
values map[string]string values map[string]string

View File

@@ -64,6 +64,12 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
cfg.RenewOnAccessEnabled = value cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled: case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value cfg.RenewOnDownloadEnabled = value
case SettingBoxOwnerEditEnabled:
cfg.BoxOwnerEditEnabled = value
case SettingBoxOwnerRefreshEnabled:
cfg.BoxOwnerRefreshEnabled = value
case SettingBoxOwnerPasswordEdit:
cfg.BoxOwnerPasswordEditEnabled = value
} }
cfg.setValue(key, formatBool(value), source) cfg.setValue(key, formatBool(value), source)
} }
@@ -82,6 +88,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds: case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value cfg.SessionTTLSeconds = value
case SettingBoxOwnerMaxRefreshAmount:
cfg.BoxOwnerMaxRefreshAmountSeconds = value
case SettingBoxOwnerMaxTotalExpiry:
cfg.BoxOwnerMaxTotalExpirySeconds = value
} }
cfg.setValue(key, strconv.FormatInt(value, 10), source) cfg.setValue(key, strconv.FormatInt(value, 10), source)
} }
@@ -94,6 +104,8 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
cfg.ThumbnailBatchSize = value cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds: case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value cfg.ThumbnailIntervalSeconds = value
case SettingBoxOwnerMaxRefreshCount:
cfg.BoxOwnerMaxRefreshCount = value
} }
cfg.setValue(key, strconv.Itoa(value), source) cfg.setValue(key, strconv.Itoa(value), source)
} }

162
lib/server/account_auth.go Normal file
View File

@@ -0,0 +1,162 @@
package server
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
const accountSessionCookie = "warpbox_account_session"
func (app *App) registerAccountRoutes(router *gin.Engine) {
account := router.Group("/account")
account.Use(noStoreAdminHeaders)
account.GET("/login", app.handleAccountLogin)
account.POST("/login", app.handleAccountLoginPost)
protected := account.Group("")
protected.Use(app.requireAccountSession)
protected.GET("", app.handleAccountDashboard)
protected.GET("/", app.handleAccountDashboard)
protected.POST("/logout", app.handleAccountLogout)
protected.GET("/settings", app.handleAccountSettings)
protected.POST("/settings", app.handleAccountSettingsPost)
protected.POST("/settings/reset", app.handleAccountSettingsReset)
protected.GET("/settings/export.json", app.handleAccountSettingsExport)
protected.POST("/settings/import.json", app.handleAccountSettingsImport)
}
func (app *App) handleAccountLogin(ctx *gin.Context) {
if app.isAccountSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/account")
return
}
app.renderAccountLogin(ctx, "")
}
func (app *App) handleAccountLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAccountLogin(ctx, "Account login is disabled.")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
user, ok, err := app.store.GetUserByUsername(username)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load user")
return
}
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
app.renderAccountLogin(ctx, "The username or password was not accepted.")
return
}
if _, err := app.permissionsForUser(user); err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not create session")
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(accountSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/account", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/account")
}
func (app *App) handleAccountLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(accountSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(accountSessionCookie, "", -1, "/account", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/account/login")
}
func (app *App) requireAccountSession(ctx *gin.Context) {
token, err := ctx.Cookie(accountSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
ctx.Set("accountUser", user)
ctx.Set("adminUser", user)
ctx.Set("accountPerms", perms)
ctx.Set("adminPerms", perms)
ctx.Set("accountSession", session)
ctx.Set("accountCSRFToken", session.CSRFToken)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAccountSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(accountSessionCookie)
if err != nil {
return false
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
return false
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
return false
}
_, err = app.permissionsForUser(user)
return err == nil
}
func (app *App) renderAccountLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "account_login.html", gin.H{
"PageTitle": "WarpBox Account Login",
"AdminLoginEnabled": app.adminLoginEnabled,
"AccountLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func currentAccountUser(ctx *gin.Context) (metastore.User, bool) {
if current, ok := ctx.Get("accountUser"); ok {
if user, ok := current.(metastore.User); ok {
return user, true
}
}
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user, true
}
}
return metastore.User{}, false
}

61
lib/server/account_nav.go Normal file
View File

@@ -0,0 +1,61 @@
package server
import (
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type AccountNavView struct {
Username string
IsAdmin bool
ActiveSection string
AlertCount int
AlertSeverity string
CanViewBoxes bool
CanViewAlerts bool
CanViewUsers bool
CanViewAPIKeys bool
CanViewSettings bool
}
func (app *App) accountNavView(ctx *gin.Context, activeSection string) AccountNavView {
perms := currentAccountPermissions(ctx)
isAdmin := perms.AdminAccess
return AccountNavView{
Username: app.currentAdminUsername(ctx),
IsAdmin: isAdmin,
ActiveSection: activeSection,
AlertSeverity: "ok",
CanViewBoxes: true,
CanViewAlerts: true,
CanViewUsers: perms.AdminUsersManage,
CanViewAPIKeys: true,
CanViewSettings: perms.AdminSettingsManage,
}
}
func currentAccountPermissions(ctx *gin.Context) metastore.EffectivePermissions {
value, ok := ctx.Get("adminPerms")
if !ok {
return metastore.EffectivePermissions{}
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok {
return metastore.EffectivePermissions{}
}
return perms
}
func normalizeAlertSeverity(severity string) string {
normalized := strings.ToLower(strings.TrimSpace(severity))
switch normalized {
case "danger", "warning", "info", "ok":
return normalized
default:
return "ok"
}
}

238
lib/server/account_pages.go Normal file
View File

@@ -0,0 +1,238 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
type AccountDashboardView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Stats AccountDashboardStats
Statuses []accountStatusRow
Alerts []accountAlertPreviewRow
RecentBoxes []accountDashboardBoxRow
RecentActivity []accountActivityRow
ShowUsersStat bool
CanManageBoxes bool
CanManageUsers bool
CanViewSettings bool
HasAlertsPreview bool
}
type AccountDashboardStats struct {
ActiveBoxes int
StorageUsedLabel string
AlertCount int
TotalUsers int
ActiveUsers int
DisabledUsers int
}
type accountStatusRow struct {
Label string
Value string
Severity string
}
type accountAlertPreviewRow struct {
Severity string
Title string
Detail string
}
type accountDashboardBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Flags string
CanManage bool
}
type accountActivityRow struct {
Time string
Title string
Meta string
}
func (app *App) handleAccountDashboard(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
view, err := app.GetAccountDashboard(ctx, actor)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load account dashboard")
return
}
ctx.HTML(http.StatusOK, "account_dashboard.html", view)
}
func (app *App) GetAccountDashboard(ctx *gin.Context, actor metastore.User) (AccountDashboardView, error) {
perms := currentAccountPermissions(ctx)
nav := app.accountNavView(ctx, "dashboard")
totalSize := int64(0)
activeBoxes := 0
recentBoxes := []accountDashboardBoxRow{}
if perms.AdminBoxesView {
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
return AccountDashboardView{}, err
}
recentBoxes = make([]accountDashboardBoxRow, 0, minInt(len(summaries), 10))
for _, summary := range summaries {
totalSize += summary.TotalSize
if !summary.Expired {
activeBoxes++
}
if len(recentBoxes) < 10 {
recentBoxes = append(recentBoxes, accountDashboardBoxRow{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Flags: accountBoxFlags(summary.Expired, summary.OneTimeDownload, summary.PasswordProtected),
CanManage: true,
})
}
}
}
stats := AccountDashboardStats{
ActiveBoxes: activeBoxes,
StorageUsedLabel: helpers.FormatBytes(totalSize),
}
showUsersStat := perms.AdminUsersManage
if showUsersStat {
users, err := app.store.ListUsers()
if err != nil {
return AccountDashboardView{}, err
}
stats.TotalUsers = len(users)
for _, user := range users {
if user.Disabled {
stats.DisabledUsers++
} else {
stats.ActiveUsers++
}
}
}
return AccountDashboardView{
PageTitle: "WarpBox Account",
WindowTitle: "WarpBox Account Control Panel",
WindowIcon: "W",
AccountNav: nav,
CSRFToken: app.currentCSRFToken(ctx),
Stats: stats,
Statuses: app.accountDashboardStatuses(),
Alerts: accountPlaceholderAlerts(),
RecentBoxes: recentBoxes,
RecentActivity: accountPlaceholderActivity(actor, ctx),
ShowUsersStat: showUsersStat,
CanManageBoxes: perms.AdminBoxesView,
CanManageUsers: perms.AdminUsersManage,
CanViewSettings: perms.AdminSettingsManage,
HasAlertsPreview: true,
}, nil
}
func (app *App) accountDashboardStatuses() []accountStatusRow {
return []accountStatusRow{
{Label: "Guest uploads", Value: enabledLabel(app.config.GuestUploadsEnabled), Severity: boolSeverity(app.config.GuestUploadsEnabled)},
{Label: "API", Value: enabledLabel(app.config.APIEnabled), Severity: boolSeverity(app.config.APIEnabled)},
{Label: "ZIP downloads", Value: enabledLabel(app.config.ZipDownloadsEnabled), Severity: boolSeverity(app.config.ZipDownloadsEnabled)},
{Label: "One-time boxes", Value: enabledLabel(app.config.OneTimeDownloadsEnabled), Severity: boolSeverity(app.config.OneTimeDownloadsEnabled)},
}
}
func accountPlaceholderAlerts() []accountAlertPreviewRow {
return []accountAlertPreviewRow{
{
Severity: "info",
Title: "Alerts system pending",
Detail: "Dedicated alert storage arrives in the alerts implementation pass.",
},
}
}
func accountPlaceholderActivity(actor metastore.User, ctx *gin.Context) []accountActivityRow {
now := time.Now().UTC()
if value, ok := ctx.Get("accountSession"); ok {
if session, ok := value.(metastore.Session); ok {
now = session.CreatedAt
}
}
return []accountActivityRow{
{
Time: formatAdminTime(now),
Title: "Signed in",
Meta: actor.Username + " opened the account dashboard.",
},
{
Time: "pending",
Title: "Audit log not implemented",
Meta: "Recent account activity will use the audit model in a later pass.",
},
}
}
func accountBoxFlags(expired bool, oneTime bool, passwordProtected bool) string {
flags := []string{}
if expired {
flags = append(flags, "expired")
}
if oneTime {
flags = append(flags, "one-time")
}
if passwordProtected {
flags = append(flags, "password")
}
if len(flags) == 0 {
return "normal"
}
out := flags[0]
for _, flag := range flags[1:] {
out += ", " + flag
}
return out
}
func enabledLabel(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
func boolSeverity(enabled bool) string {
if enabled {
return "ok"
}
return "warn"
}
func minInt(a int, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,506 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
type SettingsView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Groups []SettingsGroupView
OverridesAllowed bool
CanEdit bool
Error string
Notice string
}
type SettingsGroupView struct {
Key string
Label string
Description string
Rows []SettingsRowView
}
type SettingsRowView struct {
Key string
Label string
Description string
Type config.SettingType
Value string
DisplayValue string
Source string
EnvName string
Editable bool
LockedReason string
Future bool
}
type SettingsBackup struct {
Version int `json:"version"`
ExportedAt string `json:"exported_at"`
Settings map[string]string `json:"settings"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type ImportResult struct {
Applied int `json:"applied"`
Keys []string `json:"keys"`
}
type settingsMeta struct {
Group string
Description string
Units string
Future bool
}
var settingsGroups = []SettingsGroupView{
{Key: "uploads", Label: "Uploads", Description: "Guest uploads and upload size defaults."},
{Key: "downloads", Label: "Downloads", Description: "ZIP and one-time download behavior."},
{Key: "retention", Label: "Retention", Description: "Expiry and renewal defaults."},
{Key: "accounts", Label: "Accounts", Description: "Session and account defaults."},
{Key: "api", Label: "API", Description: "API surface toggles."},
{Key: "storage", Label: "Storage", Description: "Storage paths and hard capacity limits."},
{Key: "workers", Label: "Workers", Description: "Background worker timing."},
{Key: "box_policy", Label: "Box policy", Description: "Defaults for future owner-managed boxes."},
}
var settingsMetadata = map[string]settingsMeta{
config.SettingGuestUploadsEnabled: {Group: "uploads", Description: "Allow guests to create upload boxes."},
config.SettingDefaultUserMaxFileBytes: {Group: "uploads", Description: "Default per-user file size limit. Zero means unlimited.", Units: "bytes"},
config.SettingDefaultUserMaxBoxBytes: {Group: "uploads", Description: "Default per-user total box size limit. Zero means unlimited.", Units: "bytes"},
config.SettingZipDownloadsEnabled: {Group: "downloads", Description: "Allow ZIP downloads when a box permits it."},
config.SettingOneTimeDownloadsEnabled: {Group: "downloads", Description: "Allow one-time ZIP handoff boxes."},
config.SettingOneTimeDownloadExpirySecs: {Group: "downloads", Description: "How long one-time downloads stay retryable or pending.", Units: "duration"},
config.SettingOneTimeDownloadRetryFail: {Group: "downloads", Description: "Keep one-time boxes retryable after a ZIP writer failure."},
config.SettingDefaultGuestExpirySecs: {Group: "retention", Description: "Default guest box expiry.", Units: "duration"},
config.SettingMaxGuestExpirySecs: {Group: "retention", Description: "Maximum guest box expiry.", Units: "duration"},
config.SettingRenewOnAccessEnabled: {Group: "retention", Description: "Allow expiry renewal when a box is opened."},
config.SettingRenewOnDownloadEnabled: {Group: "retention", Description: "Allow expiry renewal when files are downloaded."},
config.SettingSessionTTLSeconds: {Group: "accounts", Description: "Account session lifetime.", Units: "duration"},
config.SettingAPIEnabled: {Group: "api", Description: "Expose API-style upload/status endpoints."},
config.SettingDataDir: {Group: "storage", Description: "Base data directory. Environment only."},
config.SettingGlobalMaxFileSizeBytes: {Group: "storage", Description: "Hard global file size cap. Environment only.", Units: "bytes"},
config.SettingGlobalMaxBoxSizeBytes: {Group: "storage", Description: "Hard global box size cap. Environment only.", Units: "bytes"},
config.SettingBoxPollIntervalMS: {Group: "workers", Description: "Browser polling cadence for box status.", Units: "milliseconds"},
config.SettingThumbnailBatchSize: {Group: "workers", Description: "Thumbnail worker batch size."},
config.SettingThumbnailIntervalSeconds: {Group: "workers", Description: "Thumbnail worker interval.", Units: "duration"},
config.SettingBoxOwnerEditEnabled: {Group: "box_policy", Description: "Default: owners may edit their boxes."},
config.SettingBoxOwnerRefreshEnabled: {Group: "box_policy", Description: "Default: owners may refresh box expiry."},
config.SettingBoxOwnerMaxRefreshCount: {Group: "box_policy", Description: "Default maximum number of owner refreshes."},
config.SettingBoxOwnerMaxRefreshAmount: {Group: "box_policy", Description: "Default maximum expiry added per owner refresh.", Units: "duration"},
config.SettingBoxOwnerMaxTotalExpiry: {Group: "box_policy", Description: "Default maximum total box expiry for owner-managed boxes.", Units: "duration"},
config.SettingBoxOwnerPasswordEdit: {Group: "box_policy", Description: "Default: owners may edit box passwords."},
}
func (app *App) handleAccountSettings(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
view, err := app.ListSettings(ctx, actor)
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.HTML(http.StatusOK, "account_settings.html", view)
}
func (app *App) handleAccountSettingsPost(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := ctx.Request.ParseForm(); err != nil {
app.renderSettingsWithMessage(ctx, actor, "could not parse settings form", "")
return
}
editable := map[string]config.SettingDefinition{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = def
}
for key := range ctx.Request.PostForm {
if key == "csrf_token" {
continue
}
if _, ok := editable[key]; ok {
continue
}
if _, ok := config.Definition(key); ok {
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("setting %q is locked", key), "")
return
}
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("unknown setting %q", key), "")
return
}
changes := map[string]string{}
for _, def := range editable {
if def.Type == config.SettingTypeBool {
value := "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
changes[def.Key] = value
continue
}
if _, exists := ctx.GetPostForm(def.Key); exists {
changes[def.Key] = ctx.PostForm(def.Key)
}
}
if err := app.UpdateSettings(ctx, actor, changes); err != nil {
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
return
}
ctx.Redirect(http.StatusSeeOther, "/account/settings")
}
func (app *App) handleAccountSettingsReset(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := app.ResetSettingOverride(ctx, actor, ctx.PostForm("key")); err != nil {
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
return
}
ctx.Redirect(http.StatusSeeOther, "/account/settings")
}
func (app *App) handleAccountSettingsExport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
backup, err := app.ExportSettings(ctx, actor)
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.Header("Content-Disposition", `attachment; filename="warpbox-settings.json"`)
ctx.JSON(http.StatusOK, backup)
}
func (app *App) handleAccountSettingsImport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if !strings.HasPrefix(strings.ToLower(ctx.GetHeader("Content-Type")), "application/json") {
ctx.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "settings import requires application/json"})
return
}
var backup SettingsBackup
if err := json.NewDecoder(ctx.Request.Body).Decode(&backup); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings JSON"})
return
}
result, err := app.ImportSettings(ctx, actor, backup)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, result)
}
func (app *App) ListSettings(ctx *gin.Context, actor metastore.User) (SettingsView, error) {
perms := currentAccountPermissions(ctx)
if !perms.AdminSettingsManage {
return SettingsView{}, fmt.Errorf("permission denied")
}
rows := app.settingsRows(perms.AdminSettingsManage && app.config.AllowAdminSettingsOverride)
groups := make([]SettingsGroupView, 0, len(settingsGroups))
for _, group := range settingsGroups {
copyGroup := group
copyGroup.Rows = rows[group.Key]
groups = append(groups, copyGroup)
}
return SettingsView{
PageTitle: "WarpBox Settings",
WindowTitle: "WarpBox Account Settings",
WindowIcon: "S",
PageScripts: []string{"/static/js/account-settings.js"},
AccountNav: app.accountNavView(ctx, "settings"),
CSRFToken: app.currentCSRFToken(ctx),
Groups: groups,
OverridesAllowed: app.config.AllowAdminSettingsOverride,
CanEdit: app.config.AllowAdminSettingsOverride,
}, nil
}
func (app *App) UpdateSettings(ctx *gin.Context, actor metastore.User, changes map[string]string) error {
if err := app.requireSettingsEdit(ctx); err != nil {
return err
}
if !app.config.AllowAdminSettingsOverride {
return fmt.Errorf("admin settings overrides are disabled")
}
if err := validateSettingChanges(changes); err != nil {
return err
}
for key, value := range changes {
if err := app.store.SetSetting(key, value); err != nil {
return err
}
}
return app.reloadRuntimeConfig()
}
func (app *App) ResetSettingOverride(ctx *gin.Context, actor metastore.User, key string) error {
if err := app.requireSettingsEdit(ctx); err != nil {
return err
}
def, ok := config.Definition(strings.TrimSpace(key))
if !ok {
return fmt.Errorf("unknown setting %q", key)
}
if !def.Editable || def.HardLimit {
return fmt.Errorf("setting %q cannot be reset from account settings", key)
}
if err := app.store.DeleteSetting(def.Key); err != nil {
return err
}
return app.reloadRuntimeConfig()
}
func (app *App) ExportSettings(ctx *gin.Context, actor metastore.User) (SettingsBackup, error) {
perms := currentAccountPermissions(ctx)
if !perms.AdminSettingsManage {
return SettingsBackup{}, fmt.Errorf("permission denied")
}
settings := map[string]string{}
for _, def := range config.EditableDefinitions() {
settings[def.Key] = app.config.SettingValue(def.Key)
}
return SettingsBackup{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Settings: settings,
Metadata: map[string]string{
"app": "WarpBox",
},
}, nil
}
func (app *App) ImportSettings(ctx *gin.Context, actor metastore.User, backup SettingsBackup) (ImportResult, error) {
if err := app.requireSettingsEdit(ctx); err != nil {
return ImportResult{}, err
}
if !app.config.AllowAdminSettingsOverride {
return ImportResult{}, fmt.Errorf("admin settings overrides are disabled")
}
if backup.Settings == nil {
return ImportResult{}, fmt.Errorf("settings backup has no settings")
}
if err := validateSettingChanges(backup.Settings); err != nil {
return ImportResult{}, err
}
keys := make([]string, 0, len(backup.Settings))
for key := range backup.Settings {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if err := app.store.SetSetting(key, backup.Settings[key]); err != nil {
return ImportResult{}, err
}
}
if err := app.reloadRuntimeConfig(); err != nil {
return ImportResult{}, err
}
return ImportResult{Applied: len(keys), Keys: keys}, nil
}
func (app *App) renderSettingsWithMessage(ctx *gin.Context, actor metastore.User, errorMessage string, notice string) {
view, err := app.ListSettings(ctx, actor)
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
view.Error = errorMessage
view.Notice = notice
ctx.HTML(http.StatusOK, "account_settings.html", view)
}
func (app *App) requireSettingsEdit(ctx *gin.Context) error {
perms := currentAccountPermissions(ctx)
if !perms.AdminSettingsManage {
return fmt.Errorf("permission denied")
}
return nil
}
func (app *App) settingsRows(canEdit bool) map[string][]SettingsRowView {
out := map[string][]SettingsRowView{}
for _, row := range app.config.SettingRows() {
meta := settingsMetadata[row.Definition.Key]
group := meta.Group
if group == "" {
group = "accounts"
}
editable := canEdit && row.Definition.Editable && !row.Definition.HardLimit
out[group] = append(out[group], SettingsRowView{
Key: row.Definition.Key,
Label: row.Definition.Label,
Description: meta.Description,
Type: row.Definition.Type,
Value: row.Value,
DisplayValue: settingDisplayValue(row.Value, meta.Units),
Source: settingSourceLabel(row.Source, row.Definition),
EnvName: row.Definition.EnvName,
Editable: editable,
LockedReason: settingLockedReason(row.Definition, canEdit),
Future: meta.Future,
})
}
return out
}
func validateSettingChanges(changes map[string]string) error {
if len(changes) == 0 {
return fmt.Errorf("no settings provided")
}
cfg, err := config.Load()
if err != nil {
return err
}
for key, value := range changes {
if _, ok := config.Definition(key); !ok {
return fmt.Errorf("unknown setting %q", key)
}
if err := cfg.ApplyOverride(key, value); err != nil {
return err
}
}
return nil
}
func (app *App) reloadRuntimeConfig() error {
cfg, err := config.Load()
if err != nil {
return err
}
overrides, err := app.store.ListSettings()
if err != nil {
return err
}
if err := cfg.ApplyOverrides(overrides); err != nil {
return err
}
app.config = cfg
applyBoxstoreRuntimeConfig(cfg)
return nil
}
func settingSourceLabel(source config.Source, def config.SettingDefinition) string {
if def.HardLimit {
return "hard env"
}
if !def.Editable {
return "locked"
}
switch source {
case config.SourceDB:
return "override"
case config.SourceEnv:
return "env"
default:
return "default"
}
}
func settingLockedReason(def config.SettingDefinition, canEdit bool) string {
if !canEdit {
return "settings changes disabled"
}
if def.HardLimit {
return "hard environment limit"
}
if !def.Editable {
return "runtime editing not supported"
}
return ""
}
func settingDisplayValue(value string, units string) string {
switch units {
case "bytes":
parsed, ok := parseInt64String(value)
if !ok {
return value
}
if parsed == 0 {
return "unlimited"
}
return fmt.Sprintf("%s (%s bytes)", formatBytesForSettings(parsed), value)
case "duration":
parsed, ok := parseInt64String(value)
if !ok {
return value
}
return fmt.Sprintf("%s (%s seconds)", formatDurationForSettings(parsed), value)
case "milliseconds":
return value + " ms"
default:
return value
}
}
func parseInt64String(value string) (int64, bool) {
var parsed int64
if _, err := fmt.Sscan(strings.TrimSpace(value), &parsed); err != nil {
return 0, false
}
return parsed, true
}
func formatBytesForSettings(value int64) string {
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
size := float64(value)
unit := 0
for size >= 1024 && unit < len(units)-1 {
size /= 1024
unit++
}
return fmt.Sprintf("%.1f %s", size, units[unit])
}
func formatDurationForSettings(seconds int64) string {
switch {
case seconds == 0:
return "none"
case seconds%86400 == 0:
return fmt.Sprintf("%d days", seconds/86400)
case seconds%3600 == 0:
return fmt.Sprintf("%d hours", seconds/3600)
case seconds%60 == 0:
return fmt.Sprintf("%d minutes", seconds/60)
default:
return fmt.Sprintf("%d seconds", seconds)
}
}

View File

@@ -0,0 +1,197 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
func TestAccountSettingsPermissionDenied(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("regular", "regular@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected permission denied, got %d", response.Code)
}
}
func TestAccountSettingsPageLoadsForAdmin(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected settings page, got %d body=%s", response.Code, response.Body.String())
}
for _, text := range []string{"Uploads", "Downloads", "Box policy", "Save Settings"} {
if !strings.Contains(response.Body.String(), text) {
t.Fatalf("expected settings page to contain %q", text)
}
}
}
func TestAccountSettingsValidUpdate(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set(config.SettingAPIEnabled, "false")
response := postAccountSettingsForm(router, session, form)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected settings redirect, got %d body=%s", response.Code, response.Body.String())
}
if app.config.APIEnabled {
t.Fatal("expected API setting to be disabled")
}
value, ok, err := app.store.GetSetting(config.SettingAPIEnabled)
if err != nil || !ok || value != "false" {
t.Fatalf("expected API setting override false, got value=%q ok=%v err=%v", value, ok, err)
}
}
func TestAccountSettingsInvalidUpdate(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set(config.SettingSessionTTLSeconds, "1")
response := postAccountSettingsForm(router, session, form)
if response.Code != http.StatusOK {
t.Fatalf("expected settings form render, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "must be at least 60") {
t.Fatal("expected validation error in response")
}
}
func TestAccountSettingsLockedSettingCannotChange(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set(config.SettingGlobalMaxFileSizeBytes, "1")
response := postAccountSettingsForm(router, session, form)
if response.Code != http.StatusOK {
t.Fatalf("expected settings form render, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "locked") {
t.Fatal("expected locked setting error")
}
if value, ok, err := app.store.GetSetting(config.SettingGlobalMaxFileSizeBytes); err != nil || ok || value != "" {
t.Fatalf("expected no locked setting override, got value=%q ok=%v err=%v", value, ok, err)
}
}
func TestAccountSettingsImportRejectsUnknownOrInvalidSettings(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
for _, body := range []string{
`{"version":1,"settings":{"not_real":"true"}}`,
`{"version":1,"settings":{"session_ttl_seconds":"1"}}`,
} {
response := postAccountSettingsJSON(router, session, body)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected bad import for %s, got %d", body, response.Code)
}
}
}
func TestAccountSettingsImportAppliesValidSettings(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
response := postAccountSettingsJSON(router, session, `{"version":1,"settings":{"api_enabled":"false","box_owner_max_refresh_count":"7"}}`)
if response.Code != http.StatusOK {
t.Fatalf("expected import success, got %d body=%s", response.Code, response.Body.String())
}
if app.config.APIEnabled {
t.Fatal("expected imported API setting to be disabled")
}
if app.config.BoxOwnerMaxRefreshCount != 7 {
t.Fatalf("expected imported box owner refresh count 7, got %d", app.config.BoxOwnerMaxRefreshCount)
}
}
func TestAccountSettingsExportShape(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
request := httptest.NewRequest(http.MethodGet, "/account/settings/export.json", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected export success, got %d", response.Code)
}
var backup SettingsBackup
if err := json.Unmarshal(response.Body.Bytes(), &backup); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if backup.Version != 1 {
t.Fatalf("expected version 1, got %d", backup.Version)
}
if _, ok := backup.Settings[config.SettingBoxOwnerMaxRefreshCount]; !ok {
t.Fatal("expected export to include box owner policy setting")
}
if _, ok := backup.Settings[config.SettingDataDir]; ok {
t.Fatal("did not expect locked data dir in export settings")
}
}
func createAccountTestSession(t *testing.T, app *App, user metastore.User) metastore.Session {
t.Helper()
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
return session
}
func postAccountSettingsForm(router http.Handler, session metastore.Session, form url.Values) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, "/account/settings", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func postAccountSettingsJSON(router http.Handler, session metastore.Session, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, "/account/settings/import.json", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-CSRF-Token", session.CSRFToken)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

245
lib/server/account_test.go Normal file
View File

@@ -0,0 +1,245 @@
package server
import (
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
func TestAccountLoginSuccess(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
response := postAccountLogin(router, "admin", "secret")
if response.Code != http.StatusSeeOther {
t.Fatalf("expected login redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/account" {
t.Fatalf("expected redirect to /account, got %q", location)
}
if cookie := findResponseCookie(response, accountSessionCookie); cookie == nil || cookie.Value == "" {
t.Fatal("expected account session cookie")
}
}
func TestAccountLoginFailure(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
response := postAccountLogin(router, "admin", "wrong")
if response.Code != http.StatusOK {
t.Fatalf("expected failed login to render form, got %d", response.Code)
}
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
t.Fatal("did not expect account session cookie")
}
if !strings.Contains(response.Body.String(), "not accepted") {
t.Fatal("expected login failure message")
}
}
func TestAccountDisabledUserLoginFailure(t *testing.T) {
app, user := setupAccountTestApp(t)
user.Disabled = true
if err := app.store.UpdateUser(user); err != nil {
t.Fatalf("UpdateUser returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
response := postAccountLogin(router, "admin", "secret")
if response.Code != http.StatusOK {
t.Fatalf("expected disabled login to render form, got %d", response.Code)
}
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
t.Fatal("did not expect account session cookie")
}
if !strings.Contains(response.Body.String(), "not accepted") {
t.Fatal("expected login failure message")
}
}
func TestAccountLogoutRequiresCSRF(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/account/logout", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected missing CSRF token to be forbidden, got %d", response.Code)
}
}
func TestAccountDashboardRequiresAuth(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
request := httptest.NewRequest(http.MethodGet, "/account", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected dashboard redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/account/login" {
t.Fatalf("expected redirect to /account/login, got %q", location)
}
}
func TestAccountDashboardLoadsForBootstrapAdmin(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/account", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected dashboard to load, got %d", response.Code)
}
body := response.Body.String()
for _, text := range []string{"Dashboard", "Recent Boxes", "Users"} {
if !strings.Contains(body, text) {
t.Fatalf("expected dashboard body to contain %q", text)
}
}
}
func TestAccountDashboardHidesAdminOnlyLinksForRegularUser(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("maya", "maya@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/account", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected dashboard to load, got %d", response.Code)
}
body := response.Body.String()
for _, text := range []string{">Users<", ">Settings<"} {
if strings.Contains(body, text) {
t.Fatalf("expected dashboard body to hide %q", text)
}
}
}
func TestAdminEntryRedirectsToAccount(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
cases := map[string]string{
"/admin/login": "/account/login",
"/admin": "/account",
}
for path, wantLocation := range cases {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected %s redirect, got %d", path, response.Code)
}
if location := response.Header().Get("Location"); location != wantLocation {
t.Fatalf("expected %s to redirect to %s, got %q", path, wantLocation, location)
}
}
}
func setupAccountTestApp(t *testing.T) (*App, metastore.User) {
t.Helper()
gin.SetMode(gin.TestMode)
restoreUploadRoot := boxstore.UploadRoot()
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
boxstore.SetUploadRoot(t.TempDir())
store, err := metastore.Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
cfg.AdminUsername = "admin"
cfg.AdminPassword = "secret"
cfg.AdminEmail = "admin@example.test"
cfg.AdminEnabled = config.AdminEnabledAuto
cfg.SessionTTLSeconds = 3600
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if bootstrap.AdminUser == nil {
t.Fatal("expected bootstrap admin user")
}
app := &App{
config: cfg,
store: store,
adminLoginEnabled: bootstrap.AdminLoginEnabled,
}
return app, *bootstrap.AdminUser
}
func setupAccountTestRouter(t *testing.T, app *App) *gin.Engine {
t.Helper()
router := gin.New()
templates, err := template.ParseGlob(filepath.Join("..", "..", "templates", "*.html"))
if err != nil {
t.Fatalf("ParseGlob returned error: %v", err)
}
router.SetHTMLTemplate(templates)
app.registerAccountRoutes(router)
app.registerAdminRoutes(router)
return router
}
func postAccountLogin(router *gin.Engine, username string, password string) *httptest.ResponseRecorder {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
request := httptest.NewRequest(http.MethodPost, "/account/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func findResponseCookie(response *httptest.ResponseRecorder, name string) *http.Cookie {
for _, cookie := range response.Result().Cookies() {
if cookie.Name == name {
return cookie
}
}
return nil
}

View File

@@ -181,6 +181,9 @@ func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
} }
token := ctx.PostForm("csrf_token") token := ctx.PostForm("csrf_token")
if token == "" {
token = ctx.GetHeader("X-CSRF-Token")
}
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken) return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
} }

View File

@@ -1,18 +1,28 @@
package server package server
import "github.com/gin-gonic/gin" import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) registerAdminRoutes(router *gin.Engine) { func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin") admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders) admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin) admin.GET("/login", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account/login")
})
admin.POST("/login", app.handleAdminLoginPost) admin.POST("/login", app.handleAdminLoginPost)
admin.GET("", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account")
})
admin.GET("/", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account")
})
protected := admin.Group("") protected := admin.Group("")
protected.Use(app.requireAdminSession) protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout) protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes) protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers) protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost) protected.POST("/users", app.handleAdminUsersPost)

View File

@@ -74,6 +74,7 @@ func Run(addr string) error {
DirectBoxUpload: app.handleDirectBoxUpload, DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload, LegacyUpload: app.handleLegacyUpload,
}) })
app.registerAccountRoutes(router)
app.registerAdminRoutes(router) app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))

1380
static/css/account.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
document.addEventListener("DOMContentLoaded", () => {
const panel = document.querySelector("[data-settings-import-panel]");
const toggle = document.querySelector("[data-settings-import-toggle]");
const submit = document.querySelector("[data-settings-import-submit]");
const input = document.querySelector("[data-settings-import-json]");
const csrf = document.querySelector('input[name="csrf_token"]')?.value || "";
toggle?.addEventListener("click", () => {
if (!panel) return;
panel.hidden = !panel.hidden;
if (!panel.hidden) input?.focus();
});
submit?.addEventListener("click", async () => {
const body = input?.value.trim() || "";
if (!body) {
window.WarpBoxAccountUI.toast("Paste settings JSON first.", "warning");
return;
}
const response = await fetch("/account/settings/import.json", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
},
body,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
window.WarpBoxAccountUI.toast(payload.error || "Settings import failed.", "error");
return;
}
window.WarpBoxAccountUI.toast(`Imported ${payload.applied || 0} settings.`, "success");
window.setTimeout(() => window.location.reload(), 700);
});
});

258
static/js/account-ui.js Normal file
View File

@@ -0,0 +1,258 @@
window.WarpBoxAccountUI = (() => {
let toastTimer = null;
let activeConfirmResolve = null;
function initStickyTaskbar(options = {}) {
const taskbar = options.taskbar || document.querySelector(".top-taskbar");
if (!taskbar) return;
const update = () => {
taskbar.classList.toggle("is-scrolled", window.scrollY > 2);
};
update();
window.addEventListener("scroll", update, { passive: true });
}
function closeMenus(root = document) {
root.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
function openMenu(item) {
if (!item) return;
closeMenus(item.closest(".menu-bar") || document);
item.classList.add("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
}
function initMenus(options = {}) {
const root = options.root || document;
root.addEventListener("click", (event) => {
const button = event.target.closest(".menu-button");
if (button) {
const item = button.closest(".menu-item");
const isOpen = item?.classList.contains("is-open");
closeMenus(root);
if (!isOpen) openMenu(item);
return;
}
if (!event.target.closest(".menu-item")) {
closeMenus(root);
}
});
root.querySelectorAll(".menu-item").forEach((item) => {
item.addEventListener("mouseenter", () => {
if (!root.querySelector(".menu-item.is-open")) return;
openMenu(item);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenus(root);
});
}
function toast(message, type = "info", options = {}) {
if (window.WarpBoxUI?.toast && !options.forceAccountToast) {
window.WarpBoxUI.toast(message, type, options);
return;
}
const target = options.target || document.querySelector("#account-toast") || document.querySelector("#toast");
if (!target) return;
target.textContent = message;
target.classList.remove("toast-info", "toast-success", "toast-warning", "toast-error", "is-visible");
target.classList.add(`toast-${type}`, "is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => target.classList.remove("is-visible"), options.duration || 2600);
}
function modalElements(options = {}) {
return {
modal: options.modal || document.querySelector("#account-modal"),
title: options.title || document.querySelector("#account-modal-title"),
body: options.body || document.querySelector("#account-modal-body"),
backdrop: options.backdrop || document.querySelector("#account-modal-backdrop") || document.querySelector("#modal-backdrop"),
};
}
function openModal(titleText, html, options = {}) {
const parts = modalElements(options);
if (!parts.modal || !parts.title || !parts.body) {
if (window.WarpBoxUI?.openPopup) {
window.WarpBoxUI.openPopup(titleText, html, options);
}
return;
}
parts.title.textContent = titleText;
if (options.text) {
parts.body.textContent = html;
} else {
parts.body.innerHTML = html;
}
parts.modal.classList.add("is-visible");
parts.backdrop?.classList.add("is-visible");
parts.modal.querySelector("[data-modal-close]")?.focus();
}
function closeModal(options = {}) {
const parts = modalElements(options);
parts.modal?.classList.remove("is-visible");
parts.backdrop?.classList.remove("is-visible");
if (window.WarpBoxUI?.closePopup && !parts.modal) {
window.WarpBoxUI.closePopup(options);
}
}
function confirm(message, options = {}) {
const title = options.title || "Confirm action";
const confirmLabel = options.confirmLabel || "OK";
const cancelLabel = options.cancelLabel || "Cancel";
const html = `
<p>${htmlEscape(message)}</p>
<div class="modal-actions">
<button class="win98-button" type="button" data-confirm-cancel>${htmlEscape(cancelLabel)}</button>
<button class="win98-button" type="button" data-confirm-ok>${htmlEscape(confirmLabel)}</button>
</div>
`;
const parts = modalElements(options);
if (!parts.modal) {
return Promise.resolve(window.confirm(message));
}
openModal(title, html, options);
return new Promise((resolve) => {
activeConfirmResolve = resolve;
parts.modal.querySelector("[data-confirm-ok]")?.focus();
});
}
function finishConfirm(result) {
if (activeConfirmResolve) {
activeConfirmResolve(result);
activeConfirmResolve = null;
}
closeModal();
}
function setDirtyState(isDirty, options = {}) {
const target = options.target || document.querySelector("[data-dirty-chip]");
if (!target) return;
target.classList.toggle("is-dirty", Boolean(isDirty));
target.textContent = isDirty ? (options.dirtyText || "unsaved changes") : (options.cleanText || "");
}
function bindFormDirtyState(form, options = {}) {
const targetForm = typeof form === "string" ? document.querySelector(form) : form;
if (!targetForm) return;
let baseline = new FormData(targetForm);
const serialize = () => new URLSearchParams(new FormData(targetForm)).toString();
let baselineValue = new URLSearchParams(baseline).toString();
const update = () => setDirtyState(serialize() !== baselineValue, options);
targetForm.addEventListener("input", update);
targetForm.addEventListener("change", update);
targetForm.addEventListener("submit", () => {
baseline = new FormData(targetForm);
baselineValue = new URLSearchParams(baseline).toString();
setDirtyState(false, options);
});
update();
}
function bindConfirmActions(root = document) {
root.addEventListener("click", async (event) => {
const ok = event.target.closest("[data-confirm-ok]");
if (ok) {
finishConfirm(true);
return;
}
const cancel = event.target.closest("[data-confirm-cancel], [data-modal-close]");
if (cancel) {
finishConfirm(false);
return;
}
const action = event.target.closest("[data-confirm]");
if (!action) return;
if (action.dataset.confirmAccepted === "true") {
delete action.dataset.confirmAccepted;
return;
}
const message = action.getAttribute("data-confirm");
if (!message) return;
event.preventDefault();
event.stopPropagation();
const accepted = await confirm(message, {
title: action.getAttribute("data-confirm-title") || "Confirm action",
confirmLabel: action.getAttribute("data-confirm-label") || "OK",
cancelLabel: action.getAttribute("data-cancel-label") || "Cancel",
});
if (!accepted) return;
if (action instanceof HTMLAnchorElement && action.href) {
window.location.href = action.href;
return;
}
const form = action.closest("form");
const type = (action.getAttribute("type") || "").toLowerCase();
if (form && (type === "submit" || type === "")) {
form.requestSubmit(action);
return;
}
action.dataset.confirmAccepted = "true";
action.click();
});
}
function htmlEscape(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function init(root = document) {
initStickyTaskbar();
initMenus({ root });
bindConfirmActions(root);
document.querySelector("#account-modal-backdrop")?.addEventListener("click", () => closeModal());
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeModal();
});
}
return {
init,
initStickyTaskbar,
initMenus,
toast,
confirm,
openModal,
closeModal,
setDirtyState,
bindFormDirtyState,
closeMenus,
};
})();
document.addEventListener("DOMContentLoaded", () => {
window.WarpBoxAccountUI.init();
});

View File

@@ -0,0 +1,198 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-dashboard-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Dashboard toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account"><span>R</span><span>Refresh dashboard</span><span class="shortcut">F5</span></a>
<div class="menu-separator"></div>
<form action="/account/logout" method="post">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
</form>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="#alerts"><span>!</span><span>Go to alerts</span><span></span></a>
<a class="menu-action" href="#recent-boxes"><span>B</span><span>Go to recent boxes</span><span></span></a>
<a class="menu-action" href="#recent-activity"><span>T</span><span>Go to recent activity</span><span></span></a>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Tools</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/boxes"><span>B</span><span>Boxes</span><span></span></a>
<a class="menu-action" href="/account/alerts"><span>!</span><span>Alerts</span><span></span></a>
{{ if .CanManageUsers }}
<a class="menu-action" href="/account/users"><span>U</span><span>Users</span><span></span></a>
{{ end }}
{{ if .CanViewSettings }}
<a class="menu-action" href="/account/settings"><span>S</span><span>Settings</span><span></span></a>
{{ end }}
</div>
</div>
</nav>
<div class="account-body-content">
<section class="dashboard-hero raised-panel" aria-labelledby="account-dashboard-title">
<div class="hero-copy">
<h2 id="account-dashboard-title">Dashboard</h2>
<p>Account overview for boxes, alerts, storage, users, and recent activity.</p>
</div>
<div class="hero-status" aria-label="System summary">
{{ range .Statuses }}
<div class="hero-status-row"><span>{{ .Label }}</span><strong class="status-{{ .Severity }}">{{ .Value }}</strong></div>
{{ end }}
</div>
</section>
<section class="stats-grid" aria-label="Dashboard statistics">
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Active boxes</p>
<p class="stat-value">{{ .Stats.ActiveBoxes }}</p>
<p class="stat-note"><span class="stat-note-pill">live filesystem scan</span></p>
</article>
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Storage used</p>
<p class="stat-value">{{ .Stats.StorageUsedLabel }}</p>
<p class="stat-note"><span class="stat-note-pill">local backend</span></p>
</article>
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Alerts</p>
<p class="stat-value">{{ .Stats.AlertCount }}</p>
<p class="stat-note"><span class="stat-note-pill">alert model pending</span></p>
</article>
{{ if .ShowUsersStat }}
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Users</p>
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.ActiveUsers }} active</span><span class="stat-note-pill">{{ .Stats.DisabledUsers }} disabled</span></p>
</article>
{{ end }}
</section>
<section class="main-grid" aria-label="Dashboard panels">
<article id="alerts" class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">!</span>
<h2>Alerts Preview</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/account/alerts">Show all</a>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel alerts-scroll">
<div class="alert-list">
{{ range .Alerts }}
<div class="alert-row">
<span class="badge is-{{ .Severity }}">{{ .Severity }}</span>
<div>
<p class="alert-title">{{ .Title }}</p>
<p class="alert-desc">{{ .Detail }}</p>
</div>
<div class="alert-actions">
<a class="tiny-button" href="/account/alerts">Open</a>
</div>
</div>
{{ else }}
<div class="alert-row">
<span class="badge is-ok">ok</span>
<div><p class="alert-title">No alerts</p><p class="alert-desc">Nothing needs attention.</p></div>
</div>
{{ end }}
</div>
</div>
</div>
</article>
<article id="recent-boxes" class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">B</span>
<h2>Recent Boxes</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/account/boxes">Show all</a>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel boxes-scroll">
<table class="account-table">
<thead>
<tr>
<th>Box</th>
<th>Files</th>
<th>Size</th>
<th>Created</th>
<th>Expires</th>
<th>Flags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .RecentBoxes }}
<tr>
<td>{{ .ID }}</td>
<td>{{ .FileCount }}</td>
<td>{{ .TotalSizeLabel }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ .ExpiresAt }}</td>
<td>{{ .Flags }}</td>
<td>
<div class="box-actions">
<a class="tiny-button" href="/box/{{ .ID }}">Open</a>
{{ if .CanManage }}
<a class="tiny-button" href="/account/boxes/{{ .ID }}">Manage</a>
{{ end }}
</div>
</td>
</tr>
{{ else }}
<tr><td colspan="7">No boxes found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</article>
<article id="recent-activity" class="win98-window section-window span-2">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">T</span>
<h2>Recent Activity</h2>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel activity-scroll">
<div class="activity-list">
{{ range .RecentActivity }}
<div class="activity-row">
<span class="activity-time">{{ .Time }}</span>
<div>
<p class="activity-title">{{ .Title }}</p>
<p class="activity-meta">{{ .Meta }}</p>
</div>
<span class="tag info">account</span>
</div>
{{ end }}
</div>
</div>
</div>
</article>
</section>
</div>
<footer class="win98-statusbar" aria-label="Dashboard status">
<span>signed in: {{ .AccountNav.Username }}</span>
<span>{{ if .AccountNav.IsAdmin }}admin{{ else }}account{{ end }}</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .PageTitle }}</title>
{{ template "account_head_assets" . }}
</head>
<body class="account-body">
<div class="app-shell">
<div class="app-frame">
<main class="account-window" aria-labelledby="account-login-title">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">W</span>
<h1 id="account-login-title">WarpBox Account Login</h1>
</div>
</div>
<div class="account-body-content">
{{ if .Error }}
<p class="account-error">{{ .Error }}</p>
{{ end }}
{{ if .AccountLoginEnabled }}
<form class="account-form sunken-panel" action="/account/login" method="post">
<label class="account-form-row">
<span>Username</span>
<input name="username" autocomplete="username" required>
</label>
<label class="account-form-row">
<span>Password</span>
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="win98-button" type="submit">Login</button>
</form>
{{ else }}
<p class="sunken-panel section-body">Account login is disabled. Set bootstrap admin credentials and restart to enable account access.</p>
{{ end }}
</div>
</main>
</div>
</div>
{{ template "account_toast_modal_containers" . }}
<script src="/static/js/account-ui.js"></script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
{{ define "account_head_assets" }}
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/account.css">
{{ end }}
{{ define "account_shell_start" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ if .PageTitle }}{{ .PageTitle }}{{ else }}WarpBox Account{{ end }}</title>
{{ template "account_head_assets" . }}
</head>
<body class="account-body">
<div class="app-shell">
<div class="app-frame">
{{ template "account_taskbar" . }}
{{ end }}
{{ define "account_shell_end" }}
</div>
</div>
{{ template "account_toast_modal_containers" . }}
<script src="/static/js/account-ui.js"></script>
{{ range .PageScripts }}
<script src="{{ . }}"></script>
{{ end }}
</body>
</html>
{{ end }}
{{ define "account_taskbar" }}
{{ $nav := .AccountNav }}
<header class="top-taskbar" aria-label="Account navigation">
<a class="start-button" href="/account">
<span class="start-logo">W</span>
<span>WarpBox</span>
</a>
<nav class="taskbar-nav" aria-label="Primary">
<a class="taskbar-button{{ if eq $nav.ActiveSection "dashboard" }} is-active{{ end }}" href="/account">Dashboard</a>
{{ if $nav.CanViewBoxes }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "boxes" }} is-active{{ end }}" href="/account/boxes">Boxes</a>
{{ end }}
{{ if $nav.CanViewAlerts }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "alerts" }} is-active{{ end }}" href="/account/alerts">Alerts</a>
{{ end }}
{{ if $nav.CanViewUsers }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "users" }} is-active{{ end }}" href="/account/users">Users</a>
{{ end }}
{{ if $nav.CanViewAPIKeys }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "api-keys" }} is-active{{ end }}" href="/account/api-keys">API Keys</a>
{{ end }}
{{ if $nav.CanViewSettings }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "settings" }} is-active{{ end }}" href="/account/settings">Settings</a>
{{ end }}
</nav>
<div class="taskbar-session" aria-label="Current session summary">
{{ if gt $nav.AlertCount 0 }}
<a class="alert-chip is-{{ $nav.AlertSeverity }}" href="/account/alerts">! {{ $nav.AlertCount }} alerts</a>
{{ else }}
<span class="alert-chip is-ok">0 alerts</span>
{{ end }}
<span class="session-chip">signed in: {{ $nav.Username }}</span>
{{ if $nav.IsAdmin }}
<span class="session-chip">admin</span>
{{ else }}
<span class="session-chip">account</span>
{{ end }}
<span class="dirty-chip" data-dirty-chip></span>
</div>
</header>
{{ end }}
{{ define "account_window_titlebar" }}
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">{{ if .WindowIcon }}{{ .WindowIcon }}{{ else }}W{{ end }}</span>
<h1>{{ if .WindowTitle }}{{ .WindowTitle }}{{ else }}WarpBox Account Control Panel{{ end }}</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button">[]</button>
<button class="win98-control" type="button">x</button>
</div>
</div>
{{ end }}
{{ define "account_csrf_field" }}
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
{{ end }}
{{ define "account_toast_modal_containers" }}
<div class="toast" id="account-toast" role="status" aria-live="polite"></div>
<div class="modal-backdrop" id="account-modal-backdrop" aria-hidden="true"></div>
<section class="account-modal win98-window" id="account-modal" role="dialog" aria-modal="true" aria-labelledby="account-modal-title">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">W</span>
<h2 id="account-modal-title">WarpBox</h2>
</div>
<div class="win98-window-controls">
<button class="win98-control" type="button" data-modal-close aria-label="Close">x</button>
</div>
</div>
<div class="modal-body sunken-panel" id="account-modal-body"></div>
</section>
{{ end }}

View File

@@ -0,0 +1,134 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-settings-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Settings toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/settings"><span>R</span><span>Refresh settings</span><span></span></a>
<a class="menu-action" href="/account/settings/export.json"><span>E</span><span>Export JSON</span><span></span></a>
<div class="menu-separator"></div>
<form action="/account/logout" method="post">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
</form>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup" role="menu">
{{ range .Groups }}
<a class="menu-action" href="#settings-{{ .Key }}"><span>S</span><span>{{ .Label }}</span><span></span></a>
{{ end }}
</div>
</div>
</nav>
<form class="settings-layout account-body-content" action="/account/settings" method="post">
{{ template "account_csrf_field" . }}
<section class="settings-summary raised-panel" aria-label="Settings status">
{{ if .Error }}<span class="badge is-danger">{{ .Error }}</span>{{ end }}
{{ if .Notice }}<span class="badge is-ok">{{ .Notice }}</span>{{ end }}
{{ if .OverridesAllowed }}
<span class="badge is-ok">overrides enabled</span>
{{ else }}
<span class="badge is-warning">read-only: overrides disabled</span>
{{ end }}
<a class="tiny-button" href="/account/settings/export.json">Export JSON</a>
<button class="tiny-button" type="button" data-settings-import-toggle>Import JSON</button>
</section>
<section class="settings-import raised-panel" data-settings-import-panel hidden>
<label class="account-form-row">
<span>Settings backup JSON</span>
<textarea class="account-control" rows="5" data-settings-import-json></textarea>
</label>
<button class="win98-button" type="button" data-settings-import-submit {{ if not .CanEdit }}disabled{{ end }}>Import</button>
</section>
<div class="settings-scroll scroll-panel" aria-label="Grouped settings">
{{ range .Groups }}
<section class="settings-group" id="settings-{{ .Key }}">
<header class="settings-group-header">
<h2>{{ .Label }}</h2>
<p>{{ .Description }}</p>
</header>
<table class="account-table settings-table">
<thead>
<tr>
<th>Setting</th>
<th>Description</th>
<th>Value</th>
<th>Source</th>
<th>Reset</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr>
<td>
<strong>{{ .Label }}</strong>
<span class="setting-key">{{ .Key }}</span>
</td>
<td><p class="setting-description">{{ .Description }}</p></td>
<td>
{{ if .Editable }}
{{ if eq .Type "bool" }}
<label class="account-checks"><span><input type="checkbox" name="{{ .Key }}" value="true" {{ if eq .Value "true" }}checked{{ end }}> enabled</span></label>
{{ else }}
<input class="account-control" name="{{ .Key }}" value="{{ .Value }}" inputmode="numeric">
<span class="setting-key">{{ .DisplayValue }}</span>
{{ end }}
{{ else }}
<span>{{ .DisplayValue }}</span>
{{ if .LockedReason }}<span class="setting-key">{{ .LockedReason }}</span>{{ end }}
{{ end }}
</td>
<td>
<span class="setting-source">
<span class="badge is-info">{{ .Source }}</span>
<span class="setting-env">{{ .EnvName }}</span>
</span>
</td>
<td>
{{ if .Editable }}
<button class="tiny-button" type="submit" form="reset-{{ .Key }}">Reset</button>
{{ else }}
<span class="badge">locked</span>
{{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="5">No settings in this group.</td></tr>
{{ end }}
</tbody>
</table>
</section>
{{ end }}
</div>
<section class="settings-actions raised-panel" aria-label="Settings actions">
<button class="win98-button" type="submit" {{ if not .CanEdit }}disabled{{ end }}>Save Settings</button>
</section>
</form>
{{ range .Groups }}
{{ range .Rows }}
{{ if .Editable }}
<form id="reset-{{ .Key }}" action="/account/settings/reset" method="post" hidden>
{{ template "account_csrf_field" $ }}
<input type="hidden" name="key" value="{{ .Key }}">
</form>
{{ end }}
{{ end }}
{{ end }}
<footer class="win98-statusbar" aria-label="Settings status">
<span>settings</span>
<span>{{ if .CanEdit }}editable{{ else }}read-only{{ end }}</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}