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:
@@ -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
|
||||||
|
|||||||
@@ -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, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
162
lib/server/account_auth.go
Normal 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
61
lib/server/account_nav.go
Normal 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
238
lib/server/account_pages.go
Normal 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
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
197
lib/server/account_settings_test.go
Normal file
197
lib/server/account_settings_test.go
Normal 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
245
lib/server/account_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
1380
static/css/account.css
Normal file
File diff suppressed because it is too large
Load Diff
39
static/js/account-settings.js
Normal file
39
static/js/account-settings.js
Normal 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
258
static/js/account-ui.js
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
198
templates/account_dashboard.html
Normal file
198
templates/account_dashboard.html
Normal 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" . }}
|
||||||
45
templates/account_login.html
Normal file
45
templates/account_login.html
Normal 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>
|
||||||
110
templates/account_partials.html
Normal file
110
templates/account_partials.html
Normal 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 }}
|
||||||
134
templates/account_settings.html
Normal file
134
templates/account_settings.html
Normal 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" . }}
|
||||||
Reference in New Issue
Block a user