Adds configuration options and environment variables to manage box owner policies, including settings for refresh counts and expiry.
507 lines
16 KiB
Go
507 lines
16 KiB
Go
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)
|
|
}
|
|
}
|