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:
506
lib/server/account_settings.go
Normal file
506
lib/server/account_settings.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user