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

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")
if token == "" {
token = ctx.GetHeader("X-CSRF-Token")
}
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}

View File

@@ -1,18 +1,28 @@
package server
import "github.com/gin-gonic/gin"
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
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.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.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)

View File

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