4 Commits

Author SHA1 Message Date
82d4dc815b feat(users): implement comprehensive user listing and control 2026-04-30 21:45:09 +03:00
89c885f637 feat(models): add box activity tracking
Adds BoxActivity model to track actions taken on a box.
Updates related endpoints and UI for activity feed.
2026-04-30 19:55:32 +03:00
e103829870 feat(models): add alert and box models
Adds comprehensive data structures for Alert and Box functionality across models.
2026-04-30 19:45:22 +03:00
2714907ff4 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.
2026-04-30 19:30:13 +03:00
65 changed files with 12585 additions and 54 deletions

18
go.mod
View File

@@ -3,43 +3,51 @@ module warpbox
go 1.23.0
require (
github.com/dgraph-io/badger/v4 v4.8.0
github.com/gin-contrib/gzip v1.0.1
github.com/gin-gonic/gin v1.10.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
golang.org/x/crypto v0.39.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

33
go.sum
View File

@@ -2,15 +2,24 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
@@ -19,6 +28,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -29,6 +43,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -36,15 +52,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -58,10 +73,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -86,6 +99,14 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=

View File

@@ -28,6 +28,12 @@ func TestDefaults(t *testing.T) {
if cfg.AdminPassword != "" {
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) {
@@ -39,6 +45,8 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
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()
if err != nil {
@@ -63,6 +71,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if !cfg.OneTimeDownloadRetryOnFailure {
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 {
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 {
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) {
@@ -181,6 +198,12 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"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, "")
}

View File

@@ -20,6 +20,12 @@ var Definitions = []SettingDefinition{
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: 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: 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 {
@@ -38,6 +44,10 @@ func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key)
}
func (cfg *Config) SettingValue(key string) string {
return cfg.values[key]
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled {
case AdminEnabledFalse:

View File

@@ -11,24 +11,30 @@ import (
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
BoxOwnerEditEnabled: true,
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.
@@ -73,6 +79,9 @@ func Load() (*Config, error) {
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{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 {
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},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{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 {
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},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
}
for _, item := range envInts {
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(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), 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 {

View File

@@ -36,6 +36,12 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
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
@@ -84,16 +90,22 @@ type Config struct {
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
BoxOwnerEditEnabled bool
BoxOwnerRefreshEnabled bool
BoxOwnerMaxRefreshCount int
BoxOwnerMaxRefreshAmountSeconds int64
BoxOwnerMaxTotalExpirySeconds int64
BoxOwnerPasswordEditEnabled bool
sources map[string]Source
values map[string]string

View File

@@ -64,6 +64,12 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
case SettingBoxOwnerEditEnabled:
cfg.BoxOwnerEditEnabled = value
case SettingBoxOwnerRefreshEnabled:
cfg.BoxOwnerRefreshEnabled = value
case SettingBoxOwnerPasswordEdit:
cfg.BoxOwnerPasswordEditEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
@@ -82,6 +88,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
case SettingBoxOwnerMaxRefreshAmount:
cfg.BoxOwnerMaxRefreshAmountSeconds = value
case SettingBoxOwnerMaxTotalExpiry:
cfg.BoxOwnerMaxTotalExpirySeconds = value
}
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
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
case SettingBoxOwnerMaxRefreshCount:
cfg.BoxOwnerMaxRefreshCount = value
}
cfg.setValue(key, strconv.Itoa(value), source)
}

247
lib/metastore/alerts.go Normal file
View File

@@ -0,0 +1,247 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
const (
AlertSeverityLow = "low"
AlertSeverityMedium = "medium"
AlertSeverityHigh = "high"
AlertStatusOpen = "open"
AlertStatusAcknowledged = "acknowledged"
AlertStatusClosed = "closed"
)
func (store *Store) CreateAlert(input AlertInput) (Alert, error) {
alert, err := normalizeAlertInput(input)
if err != nil {
return Alert{}, err
}
id, err := helpers.RandomHexID(16)
if err != nil {
return Alert{}, err
}
now := time.Now().UTC()
alert.ID = id
alert.Status = AlertStatusOpen
alert.CreatedAt = now
alert.UpdatedAt = now
err = store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, alertKey(alert.ID), alert)
})
return alert, err
}
func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) {
alerts := []Alert{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("alert/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var alert Alert
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &alert)
}); err != nil {
return err
}
if alertMatchesFilters(alert, filters) {
alerts = append(alerts, alert)
}
}
return nil
})
if err != nil {
return nil, err
}
sortAlerts(alerts, filters.Sort)
return alerts, nil
}
func (store *Store) GetAlert(id string) (Alert, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return Alert{}, false, nil
}
var alert Alert
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, alertKey(id), &alert)
})
if errors.Is(err, ErrNotFound) {
return Alert{}, false, nil
}
return alert, err == nil, err
}
func (store *Store) AcknowledgeAlert(id string) error {
return store.updateAlertStatus(id, AlertStatusAcknowledged)
}
func (store *Store) CloseAlert(id string) error {
return store.updateAlertStatus(id, AlertStatusClosed)
}
func (store *Store) updateAlertStatus(id string, status string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid)
}
status, err := normalizeAlertStatus(status)
if err != nil {
return err
}
now := time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
var alert Alert
if err := getJSON(txn, alertKey(id), &alert); err != nil {
return err
}
alert.Status = status
alert.UpdatedAt = now
switch status {
case AlertStatusAcknowledged:
alert.AcknowledgedAt = &now
case AlertStatusClosed:
alert.ClosedAt = &now
}
return putJSON(txn, alertKey(id), alert)
})
}
func normalizeAlertInput(input AlertInput) (Alert, error) {
title := strings.TrimSpace(input.Title)
description := strings.TrimSpace(input.Description)
code := strings.TrimSpace(input.Code)
trace := strings.TrimSpace(input.Trace)
severity, err := normalizeAlertSeverity(input.Severity)
if err != nil {
return Alert{}, err
}
if title == "" {
return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid)
}
if code == "" {
return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid)
}
if trace == "" {
return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid)
}
metadata := input.Metadata
if len(metadata) == 0 {
metadata = json.RawMessage(`{}`)
}
var object map[string]any
if err := json.Unmarshal(metadata, &object); err != nil {
return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid)
}
normalizedMetadata, err := json.Marshal(object)
if err != nil {
return Alert{}, err
}
return Alert{
Title: title,
Description: description,
Severity: severity,
Code: code,
Trace: trace,
Metadata: normalizedMetadata,
CreatedBy: strings.TrimSpace(input.CreatedBy),
}, nil
}
func normalizeAlertSeverity(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh:
return strings.ToLower(strings.TrimSpace(value)), nil
default:
return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid)
}
}
func normalizeAlertStatus(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed:
return strings.ToLower(strings.TrimSpace(value)), nil
default:
return "", fmt.Errorf("%w: invalid alert status", ErrInvalid)
}
}
func alertMatchesFilters(alert Alert, filters AlertFilters) bool {
query := strings.ToLower(strings.TrimSpace(filters.Query))
if query != "" {
haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " "))
if !strings.Contains(haystack, query) {
return false
}
}
if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity {
return false
}
if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status {
return false
}
if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group {
return false
}
return true
}
func sortAlerts(alerts []Alert, sortKey string) {
switch strings.ToLower(strings.TrimSpace(sortKey)) {
case "oldest":
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) })
case "severity":
sort.Slice(alerts, func(i int, j int) bool {
left := alertSeverityRank(alerts[i].Severity)
right := alertSeverityRank(alerts[j].Severity)
if left == right {
return alerts[i].CreatedAt.After(alerts[j].CreatedAt)
}
return left > right
})
default:
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) })
}
}
func alertSeverityRank(severity string) int {
switch severity {
case AlertSeverityHigh:
return 3
case AlertSeverityMedium:
return 2
default:
return 1
}
}
func alertGroup(trace string) string {
trace = strings.TrimSpace(trace)
if trace == "" {
return "system"
}
before, _, found := strings.Cut(trace, ".")
if !found || before == "" {
return "system"
}
return strings.ToLower(before)
}
func alertKey(id string) []byte {
return []byte("alert/" + strings.TrimSpace(id))
}

View File

@@ -0,0 +1,89 @@
package metastore
import (
"encoding/json"
"testing"
)
func TestAlertCreateListFilterLifecycle(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
alert, err := store.CreateAlert(AlertInput{
Title: "Thumbnail failed",
Description: "Could not generate preview.",
Severity: AlertSeverityMedium,
Code: "601",
Trace: "thumbnail.generate.failed",
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
CreatedBy: "system",
})
if err != nil {
t.Fatalf("CreateAlert returned error: %v", err)
}
if alert.ID == "" || alert.Status != AlertStatusOpen {
t.Fatalf("unexpected alert: %#v", alert)
}
alerts, err := store.ListAlerts(AlertFilters{Severity: AlertSeverityMedium, Status: AlertStatusOpen})
if err != nil {
t.Fatalf("ListAlerts returned error: %v", err)
}
if len(alerts) != 1 || alerts[0].Trace != "thumbnail.generate.failed" {
t.Fatalf("unexpected filtered alerts: %#v", alerts)
}
if !json.Valid(alerts[0].Metadata) {
t.Fatalf("expected valid metadata JSON: %s", string(alerts[0].Metadata))
}
var metadata map[string]string
if err := json.Unmarshal(alerts[0].Metadata, &metadata); err != nil {
t.Fatalf("Unmarshal metadata returned error: %v", err)
}
if metadata["file"] != "photo.jpg" {
t.Fatalf("metadata did not survive round trip: %#v", metadata)
}
if err := store.AcknowledgeAlert(alert.ID); err != nil {
t.Fatalf("AcknowledgeAlert returned error: %v", err)
}
acknowledged, ok, err := store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if acknowledged.Status != AlertStatusAcknowledged || acknowledged.AcknowledgedAt == nil {
t.Fatalf("expected acknowledged alert, got %#v", acknowledged)
}
if err := store.CloseAlert(alert.ID); err != nil {
t.Fatalf("CloseAlert returned error: %v", err)
}
closed, ok, err := store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if closed.Status != AlertStatusClosed || closed.ClosedAt == nil {
t.Fatalf("expected closed alert, got %#v", closed)
}
}
func TestAlertRejectsInvalidMetadata(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
if _, err := store.CreateAlert(AlertInput{
Title: "Bad alert",
Severity: AlertSeverityLow,
Code: "999",
Trace: "test.bad",
Metadata: json.RawMessage(`[]`),
}); err == nil {
t.Fatal("expected non-object metadata to be rejected")
}
}

View File

@@ -0,0 +1,71 @@
package metastore
import (
"strings"
"warpbox/lib/config"
)
func BootstrapAdmin(cfg *config.Config, store *Store) (BootstrapResult, error) {
adminTag, err := store.EnsureAdminTag()
if err != nil {
return BootstrapResult{}, err
}
var adminUser *User
user, ok, err := store.GetUserByUsername(cfg.AdminUsername)
if err != nil {
return BootstrapResult{}, err
}
if ok {
if !hasString(user.TagIDs, adminTag.ID) {
user.TagIDs = append(user.TagIDs, adminTag.ID)
if err := store.UpdateUser(user); err != nil {
return BootstrapResult{}, err
}
}
adminUser = &user
} else if strings.TrimSpace(cfg.AdminPassword) != "" {
created, err := store.CreateUserWithPassword(cfg.AdminUsername, cfg.AdminEmail, cfg.AdminPassword, []string{adminTag.ID})
if err != nil {
return BootstrapResult{}, err
}
adminUser = &created
}
hasAdminUser, err := store.HasAdminUser(adminTag.ID)
if err != nil {
return BootstrapResult{}, err
}
return BootstrapResult{
AdminTag: adminTag,
AdminUser: adminUser,
AdminLoginEnabled: cfg.AdminLoginEnabled(hasAdminUser),
}, nil
}
func (store *Store) HasAdminUser(adminTagID string) (bool, error) {
users, err := store.ListUsers()
if err != nil {
return false, err
}
for _, user := range users {
if user.Disabled {
continue
}
if hasString(user.TagIDs, adminTagID) {
return true, nil
}
}
return false, nil
}
func hasString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

188
lib/metastore/boxes.go Normal file
View File

@@ -0,0 +1,188 @@
package metastore
import (
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
)
func (store *Store) UpsertBoxRecord(record BoxRecord) error {
record.ID = strings.TrimSpace(record.ID)
if record.ID == "" {
return errors.New("box id cannot be empty")
}
record.OwnerID = strings.TrimSpace(record.OwnerID)
record.OwnerUsername = strings.TrimSpace(record.OwnerUsername)
record.FileNames = uniqueStrings(record.FileNames)
record.UpdatedAt = time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, boxRecordKey(record.ID), record)
})
}
func (store *Store) GetBoxRecord(id string) (BoxRecord, bool, error) {
var record BoxRecord
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, boxRecordKey(id), &record)
})
if errors.Is(err, ErrNotFound) {
return BoxRecord{}, false, nil
}
return record, err == nil, err
}
func (store *Store) DeleteBoxRecord(id string) error {
return store.db.Update(func(txn *badger.Txn) error {
err := txn.Delete(boxRecordKey(id))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
func (store *Store) ListBoxRecords(filters BoxFilters, page BoxPageRequest) (BoxRecordPage, error) {
if page.Page < 1 {
page.Page = 1
}
switch page.PageSize {
case 25, 50, 100:
default:
page.PageSize = 25
}
rows := []BoxRecord{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("box_record/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var record BoxRecord
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &record)
}); err != nil {
return err
}
if boxRecordMatches(record, filters) {
rows = append(rows, record)
}
}
return nil
})
if err != nil {
return BoxRecordPage{}, err
}
sortBoxRecords(rows, filters.Sort)
total := len(rows)
start := (page.Page - 1) * page.PageSize
if start > total {
start = total
}
end := start + page.PageSize
if end > total {
end = total
}
totalPages := 1
if total > 0 {
totalPages = (total + page.PageSize - 1) / page.PageSize
}
return BoxRecordPage{
Rows: rows[start:end],
Page: page.Page,
PageSize: page.PageSize,
Total: total,
HasPrev: page.Page > 1,
HasNext: end < total,
PrevPage: maxInt(page.Page-1, 1),
NextPage: page.Page + 1,
TotalPages: totalPages,
}, nil
}
func boxRecordMatches(record BoxRecord, filters BoxFilters) bool {
query := strings.ToLower(strings.TrimSpace(filters.Query))
if query != "" {
haystack := strings.ToLower(record.ID + " " + record.OwnerUsername + " " + strings.Join(record.FileNames, " "))
if !strings.Contains(haystack, query) {
return false
}
}
owner := strings.ToLower(strings.TrimSpace(filters.Owner))
if owner != "" && owner != "all" && strings.ToLower(record.OwnerUsername) != owner && strings.ToLower(record.OwnerID) != owner {
return false
}
status := strings.ToLower(strings.TrimSpace(filters.Status))
if status != "" && status != "all" && boxRecordStatus(record) != status {
return false
}
switch strings.ToLower(strings.TrimSpace(filters.Flag)) {
case "", "all":
return true
case "password":
return record.PasswordProtected
case "one-time":
return record.OneTimeDownload
case "zip-disabled":
return record.DisableZip
case "expired":
return boxRecordExpired(record)
case "refreshable":
return !record.OneTimeDownload && !boxRecordExpired(record)
default:
return false
}
}
func sortBoxRecords(rows []BoxRecord, sortKey string) {
switch strings.ToLower(strings.TrimSpace(sortKey)) {
case "oldest":
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.Before(rows[j].CreatedAt) })
case "largest":
sort.Slice(rows, func(i int, j int) bool { return rows[i].TotalSize > rows[j].TotalSize })
case "expires":
sort.Slice(rows, func(i int, j int) bool { return rows[i].ExpiresAt.Before(rows[j].ExpiresAt) })
case "expired":
sort.Slice(rows, func(i int, j int) bool {
left := boxRecordExpired(rows[i])
right := boxRecordExpired(rows[j])
if left == right {
return rows[i].CreatedAt.After(rows[j].CreatedAt)
}
return left
})
default:
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.After(rows[j].CreatedAt) })
}
}
func boxRecordStatus(record BoxRecord) string {
if boxRecordExpired(record) {
return "expired"
}
if record.ExpiresAt.IsZero() {
return "pending"
}
return "active"
}
func boxRecordExpired(record BoxRecord) bool {
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
}
func boxRecordKey(id string) []byte {
return []byte("box_record/" + strings.TrimSpace(id))
}
func maxInt(a int, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -0,0 +1,222 @@
package metastore
import (
"errors"
"testing"
"time"
"warpbox/lib/config"
)
func TestOpenClose(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if err := store.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}
func TestBootstrapAdminFromPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret-pass")
t.Setenv("WARPBOX_ADMIN_EMAIL", "admin@example.test")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if !result.AdminLoginEnabled {
t.Fatal("expected admin login to be enabled")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to be protected")
}
if result.AdminUser == nil {
t.Fatal("expected bootstrap admin user")
}
if !hasString(result.AdminUser.TagIDs, result.AdminTag.ID) {
t.Fatal("expected bootstrap admin to have admin tag")
}
if !VerifyPassword(result.AdminUser.PasswordHash, "secret-pass") {
t.Fatal("expected bootstrap admin password to verify")
}
}
func TestBootstrapAdminDisabledWithoutPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if result.AdminLoginEnabled {
t.Fatal("expected admin login to be disabled without password or existing admin")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to still be created")
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("ListUsers returned error: %v", err)
}
if len(users) != 0 {
t.Fatalf("expected no users, got %d", len(users))
}
}
func TestDuplicateUsersAndTags(t *testing.T) {
store := openTestStore(t)
if _, err := store.CreateUserWithPassword("alex", "alex@example.test", "secret", nil); err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
if _, err := store.CreateUserWithPassword("Alex", "other@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate username error, got %v", err)
}
if _, err := store.CreateUserWithPassword("other", "alex@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate email error, got %v", err)
}
tag := Tag{Name: "staff"}
if err := store.CreateTag(&tag); err != nil {
t.Fatalf("CreateTag returned error: %v", err)
}
duplicate := Tag{Name: "Staff"}
if err := store.CreateTag(&duplicate); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate tag error, got %v", err)
}
}
func TestPermissionResolutionAndGlobalCaps(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "50")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "1000")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
tagFileLimit := int64(80)
tagBoxLimit := int64(2000)
userFileLimit := int64(60)
user := User{MaxFileSizeBytes: &userFileLimit}
tags := []Tag{
{
Permissions: TagPermissions{
UploadAllowed: true,
AllowedExpirySeconds: []int64{3600, 600},
MaxFileSizeBytes: &tagFileLimit,
MaxBoxSizeBytes: &tagBoxLimit,
ZipDownloadAllowed: true,
},
},
}
perms := ResolveUserPermissions(cfg, user, tags)
if !perms.UploadAllowed || !perms.ZipDownloadAllowed {
t.Fatal("expected tag booleans to grant permissions")
}
if perms.MaxFileSizeBytes != 80 {
t.Fatalf("expected tag limit to beat user/default limit, got %d", perms.MaxFileSizeBytes)
}
if perms.MaxBoxSizeBytes != 1000 {
t.Fatalf("expected global max box cap, got %d", perms.MaxBoxSizeBytes)
}
if len(perms.AllowedExpirySeconds) != 2 || perms.AllowedExpirySeconds[0] != 600 || perms.AllowedExpirySeconds[1] != 3600 {
t.Fatalf("unexpected expiry durations: %#v", perms.AllowedExpirySeconds)
}
}
func TestSettingsStorageAndPrecedence(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_API_ENABLED", "true")
store := openTestStore(t)
if err := store.SetSetting(config.SettingAPIEnabled, "false"); err != nil {
t.Fatalf("SetSetting returned error: %v", err)
}
overrides, err := store.ListSettings()
if err != nil {
t.Fatalf("ListSettings returned error: %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.ApplyOverrides(overrides); err != nil {
t.Fatalf("ApplyOverrides returned error: %v", err)
}
if cfg.APIEnabled {
t.Fatal("expected stored DB override to beat env")
}
}
func TestSessionExpiry(t *testing.T) {
store := openTestStore(t)
session, err := store.CreateSession("user-id", time.Millisecond)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
time.Sleep(2 * time.Millisecond)
if _, ok, err := store.GetSession(session.Token); err != nil || ok {
t.Fatalf("expected expired session to be invalid, ok=%v err=%v", ok, err)
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func clearMetastoreConfigEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"WARPBOX_DATA_DIR",
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
"WARPBOX_GUEST_UPLOADS_ENABLED",
"WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}

242
lib/metastore/models.go Normal file
View File

@@ -0,0 +1,242 @@
package metastore
import (
"encoding/json"
"time"
)
const AdminTagName = "admin"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
AdminNote string `json:"admin_note,omitempty"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
PermOverrides *UserPermOverrides `json:"perm_overrides,omitempty"`
}
type UserPermOverrides struct {
UploadAllowed *bool `json:"upload_allowed,omitempty"`
ManageOwnBoxes *bool `json:"manage_own_boxes,omitempty"`
ZipDownloadAllowed *bool `json:"zip_download_allowed,omitempty"`
OneTimeDownloadAllowed *bool `json:"one_time_download_allowed,omitempty"`
RenewableAllowed *bool `json:"renewable_allowed,omitempty"`
AllowPasswordProtected *bool `json:"allow_password_protected,omitempty"`
RenewOnAccess *bool `json:"renew_on_access,omitempty"`
RenewOnDownload *bool `json:"renew_on_download,omitempty"`
AllowOwnerBoxEditing *bool `json:"allow_owner_box_editing,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Protected bool `json:"protected"`
Permissions TagPermissions `json:"permissions"`
}
type TagPermissions struct {
UploadAllowed bool `json:"upload_allowed"`
AllowedExpirySeconds []int64 `json:"allowed_expiry_seconds,omitempty"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
OneTimeDownloadAllowed bool `json:"one_time_download_allowed"`
ZipDownloadAllowed bool `json:"zip_download_allowed"`
RenewableAllowed bool `json:"renewable_allowed"`
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
AdminAccess bool `json:"admin_access"`
AdminUsersView bool `json:"admin_users_view"`
AdminUsersManage bool `json:"admin_users_manage"`
AdminSettingsManage bool `json:"admin_settings_manage"`
AdminBoxesView bool `json:"admin_boxes_view"`
}
type Session struct {
Token string `json:"token"`
CSRFToken string `json:"csrf_token"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type EffectivePermissions struct {
UploadAllowed bool
AllowedExpirySeconds []int64
MaxFileSizeBytes int64
MaxBoxSizeBytes int64
MaxExpirySeconds int64
OneTimeDownloadAllowed bool
ZipDownloadAllowed bool
RenewableAllowed bool
RenewOnAccessSeconds int64
RenewOnDownloadSeconds int64
AdminAccess bool
AdminUsersView bool
AdminUsersManage bool
AdminSettingsManage bool
AdminBoxesView bool
}
type BootstrapResult struct {
AdminTag Tag
AdminUser *User
AdminLoginEnabled bool
}
type Alert struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Status string `json:"status"`
Code string `json:"code"`
Trace string `json:"trace"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
CreatedBy string `json:"created_by"`
}
type AlertInput struct {
Title string
Description string
Severity string
Code string
Trace string
Metadata json.RawMessage
CreatedBy string
}
type AlertFilters struct {
Query string
Severity string
Status string
Group string
Sort string
}
type BoxRecord struct {
ID string `json:"id"`
OwnerID string `json:"owner_id,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
FileNames []string `json:"file_names,omitempty"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
DisableZip bool `json:"disable_zip"`
RefreshCount int `json:"refresh_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type BoxFilters struct {
Query string
Owner string
Status string
Flag string
Sort string
}
type BoxPageRequest struct {
Page int
PageSize int
}
type BoxRecordPage struct {
Rows []BoxRecord
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
TotalPages int
}
type UserFilters struct {
Query string
Status string
Role string
Sort string
}
type UserPageRequest struct {
Page int
PageSize int
}
type UserRow struct {
ID string
Username string
Email string
Status string
Role string
TagIDs []string
Tags string
Plan string
PolicySummary string
BoxCount int
APIKeyCount int
CreatedAt string
LastSeen string
Disabled bool
IsCurrent bool
IsInvite bool
}
type UserPage struct {
Rows []UserRow
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
TotalPages int
Stats UserPageStats
}
type UserPageStats struct {
TotalUsers int
ActiveUsers int
PendingInvites int
DisabledUsers int
}
type CreateUserInput struct {
Username string
Email string
Password string
Mode string
Role string
Plan string
AdminNote string
SendSetup bool
ForceChange bool
}
type CreateUserResult struct {
User User
InviteToken string
InviteLink string
IsInvite bool
PasswordSet string
InviteNotSent bool
}

View File

@@ -0,0 +1,157 @@
package metastore
import (
"sort"
"warpbox/lib/config"
)
func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) EffectivePermissions {
perms := EffectivePermissions{
MaxFileSizeBytes: cfg.DefaultUserMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.DefaultUserMaxBoxSizeBytes,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
}
expirySet := make(map[int64]bool)
for _, tag := range tags {
tagPerms := tag.Permissions
perms.UploadAllowed = perms.UploadAllowed || tagPerms.UploadAllowed
perms.OneTimeDownloadAllowed = perms.OneTimeDownloadAllowed || tagPerms.OneTimeDownloadAllowed
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
perms.AdminUsersView = perms.AdminUsersView || tagPerms.AdminUsersView
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
perms.RenewOnAccessSeconds = maxInt64(perms.RenewOnAccessSeconds, tagPerms.RenewOnAccessSeconds)
perms.RenewOnDownloadSeconds = maxInt64(perms.RenewOnDownloadSeconds, tagPerms.RenewOnDownloadSeconds)
if tagPerms.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *tagPerms.MaxFileSizeBytes)
}
if tagPerms.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *tagPerms.MaxBoxSizeBytes)
}
for _, seconds := range tagPerms.AllowedExpirySeconds {
if seconds >= 0 {
expirySet[seconds] = true
}
}
}
if user.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *user.MaxFileSizeBytes)
}
if user.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *user.MaxBoxSizeBytes)
}
if user.MaxExpirySeconds != nil {
perms.MaxExpirySeconds = *user.MaxExpirySeconds
}
if o := user.PermOverrides; o != nil {
if o.UploadAllowed != nil {
perms.UploadAllowed = *o.UploadAllowed
}
if o.ZipDownloadAllowed != nil {
perms.ZipDownloadAllowed = *o.ZipDownloadAllowed
}
if o.OneTimeDownloadAllowed != nil {
perms.OneTimeDownloadAllowed = *o.OneTimeDownloadAllowed
}
if o.RenewableAllowed != nil {
perms.RenewableAllowed = *o.RenewableAllowed
}
}
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
if !cfg.ZipDownloadsEnabled {
perms.ZipDownloadAllowed = false
}
if !cfg.OneTimeDownloadsEnabled {
perms.OneTimeDownloadAllowed = false
}
return perms
}
func ResolveGuestPermissions(cfg *config.Config) EffectivePermissions {
return EffectivePermissions{
UploadAllowed: cfg.GuestUploadsEnabled,
AllowedExpirySeconds: guestExpirySeconds(cfg),
MaxFileSizeBytes: cfg.GlobalMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.GlobalMaxBoxSizeBytes,
MaxExpirySeconds: cfg.MaxGuestExpirySeconds,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
RenewableAllowed: cfg.RenewOnAccessEnabled || cfg.RenewOnDownloadEnabled,
}
}
func morePermissiveLimit(current int64, candidate int64) int64 {
if current == 0 || candidate == 0 {
return 0
}
if candidate > current {
return candidate
}
return current
}
func capLimit(value int64, globalMax int64) int64 {
if globalMax == 0 {
return value
}
if value == 0 || value > globalMax {
return globalMax
}
return value
}
func sortedExpirySet(expirySet map[int64]bool) []int64 {
values := make([]int64, 0, len(expirySet))
for value := range expirySet {
values = append(values, value)
}
sort.Slice(values, func(i int, j int) bool {
return values[i] < values[j]
})
return values
}
func guestExpirySeconds(cfg *config.Config) []int64 {
values := []int64{}
if cfg.DefaultGuestExpirySeconds >= 0 {
values = append(values, cfg.DefaultGuestExpirySeconds)
}
if cfg.MaxGuestExpirySeconds > 0 && cfg.MaxGuestExpirySeconds != cfg.DefaultGuestExpirySeconds {
values = append(values, cfg.MaxGuestExpirySeconds)
}
return uniqueInt64s(values)
}
func uniqueInt64s(values []int64) []int64 {
seen := make(map[int64]bool, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i int, j int) bool {
return out[i] < out[j]
})
return out
}
func maxInt64(a int64, b int64) int64 {
if b > a {
return b
}
return a
}

79
lib/metastore/sessions.go Normal file
View File

@@ -0,0 +1,79 @@
package metastore
import (
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func (store *Store) CreateSession(userID string, ttl time.Duration) (Session, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return Session{}, fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
if ttl <= 0 {
return Session{}, fmt.Errorf("%w: session ttl must be positive", ErrInvalid)
}
token, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
csrfToken, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
now := time.Now().UTC()
session := Session{
Token: token,
CSRFToken: csrfToken,
UserID: userID,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
err = store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, sessionKey(token), session)
})
return session, err
}
func (store *Store) GetSession(token string) (Session, bool, error) {
token = strings.TrimSpace(token)
if token == "" {
return Session{}, false, nil
}
var session Session
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, sessionKey(token), &session)
})
if errors.Is(err, ErrNotFound) {
return Session{}, false, nil
}
if err != nil {
return Session{}, false, err
}
if time.Now().UTC().After(session.ExpiresAt) {
_ = store.DeleteSession(token)
return Session{}, false, nil
}
return session, true, nil
}
func (store *Store) DeleteSession(token string) error {
return store.db.Update(func(txn *badger.Txn) error {
err := txn.Delete(sessionKey(token))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
func sessionKey(token string) []byte {
return []byte("session/" + strings.TrimSpace(token))
}

669
lib/metastore/store.go Normal file
View File

@@ -0,0 +1,669 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
)
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate")
ErrInvalid = errors.New("invalid")
)
type Store struct {
db *badger.DB
}
func Open(path string) (*Store, error) {
opts := badger.DefaultOptions(path).WithLogger(nil)
db, err := badger.Open(opts)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (store *Store) Close() error {
if store == nil || store.db == nil {
return nil
}
return store.db.Close()
}
func (store *Store) SetSetting(name string, value string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: setting name cannot be empty", ErrInvalid)
}
return store.db.Update(func(txn *badger.Txn) error {
return txn.Set(settingKey(name), []byte(value))
})
}
func (store *Store) DeleteSetting(name string) error {
return store.db.Update(func(txn *badger.Txn) error {
return txn.Delete(settingKey(name))
})
}
func (store *Store) GetSetting(name string) (string, bool, error) {
var value string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(settingKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
value = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return "", false, nil
}
return value, err == nil, err
}
func (store *Store) ListSettings() (map[string]string, error) {
settings := make(map[string]string)
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("setting/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
name := strings.TrimPrefix(string(item.Key()), "setting/")
if err := item.Value(func(data []byte) error {
settings[name] = string(data)
return nil
}); err != nil {
return err
}
}
return nil
})
return settings, err
}
func HashPassword(password string) (string, error) {
if strings.TrimSpace(password) == "" {
return "", fmt.Errorf("%w: password cannot be empty", ErrInvalid)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func VerifyPassword(hash string, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (store *Store) CreateUserWithPassword(username string, email string, password string, tagIDs []string) (User, error) {
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
user := User{
Username: username,
Email: email,
PasswordHash: hash,
TagIDs: uniqueStrings(tagIDs),
}
if err := store.CreateUser(&user); err != nil {
return User{}, err
}
return user, nil
}
func (store *Store) CreateUser(user *User) error {
if user == nil {
return fmt.Errorf("%w: user cannot be nil", ErrInvalid)
}
username := strings.TrimSpace(user.Username)
if username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
email := strings.TrimSpace(user.Email)
if user.PasswordHash == "" {
return fmt.Errorf("%w: password hash cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if user.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
user.ID = id
}
user.Username = username
user.Email = email
user.TagIDs = uniqueStrings(user.TagIDs)
user.CreatedAt = now
user.UpdatedAt = now
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, usernameKey(username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if email != "" {
if exists, err := keyExists(txn, emailKey(email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
}
if err := putJSON(txn, userKey(user.ID), user); err != nil {
return err
}
if err := txn.Set(usernameKey(username), []byte(user.ID)); err != nil {
return err
}
if email != "" {
return txn.Set(emailKey(email), []byte(user.ID))
}
return nil
})
}
func (store *Store) UpdateUser(user User) error {
if strings.TrimSpace(user.ID) == "" {
return fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
user.Username = strings.TrimSpace(user.Username)
user.Email = strings.TrimSpace(user.Email)
if user.Username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
user.TagIDs = uniqueStrings(user.TagIDs)
user.UpdatedAt = time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
var existing User
if err := getJSON(txn, userKey(user.ID), &existing); err != nil {
return err
}
oldUsername := normalizeIndex(existing.Username)
newUsername := normalizeIndex(user.Username)
if oldUsername != newUsername {
if exists, err := keyExists(txn, usernameKey(user.Username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if err := txn.Delete(usernameKey(existing.Username)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(usernameKey(user.Username), []byte(user.ID)); err != nil {
return err
}
}
oldEmail := normalizeIndex(existing.Email)
newEmail := normalizeIndex(user.Email)
if oldEmail != newEmail {
if newEmail != "" {
if exists, err := keyExists(txn, emailKey(user.Email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
if err := txn.Set(emailKey(user.Email), []byte(user.ID)); err != nil {
return err
}
}
if oldEmail != "" {
if err := txn.Delete(emailKey(existing.Email)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
}
}
return putJSON(txn, userKey(user.ID), user)
})
}
func (store *Store) GetUser(id string) (User, bool, error) {
var user User
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, userKey(id), &user)
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
return user, err == nil, err
}
func (store *Store) GetUserByUsername(username string) (User, bool, error) {
return store.getUserByIndex(usernameKey(username))
}
func (store *Store) GetUserByEmail(email string) (User, bool, error) {
return store.getUserByIndex(emailKey(email))
}
func (store *Store) ListUsers() ([]User, error) {
users := []User{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("user/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var user User
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &user)
}); err != nil {
return err
}
users = append(users, user)
}
return nil
})
return users, err
}
func (store *Store) ListUsersPaginated(filters UserFilters, pageReq UserPageRequest) (UserPage, error) {
users, err := store.ListUsers()
if err != nil {
return UserPage{}, err
}
tags, err := store.ListTags()
if err != nil {
return UserPage{}, err
}
tagMap := make(map[string]Tag, len(tags))
for _, tag := range tags {
tagMap[tag.ID] = tag
}
query := strings.ToLower(strings.TrimSpace(filters.Query))
filtered := make([]User, 0, len(users))
for _, user := range users {
if query != "" {
if !strings.Contains(strings.ToLower(user.Username), query) &&
!strings.Contains(strings.ToLower(user.Email), query) {
continue
}
}
switch filters.Status {
case "active":
if user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
continue
}
case "disabled":
if !user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
continue
}
case "pending":
if !strings.HasPrefix(user.PasswordHash, "invite/") {
continue
}
}
if filters.Role != "" && filters.Role != "all" {
match := false
for _, tagID := range user.TagIDs {
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, filters.Role) {
match = true
break
}
}
if !match {
continue
}
}
filtered = append(filtered, user)
}
switch filters.Sort {
case "createdDesc":
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
})
case "username":
fallthrough
default:
sort.Slice(filtered, func(i, j int) bool {
return strings.ToLower(filtered[i].Username) < strings.ToLower(filtered[j].Username)
})
}
total := len(filtered)
pageSize := pageReq.PageSize
if pageSize <= 0 {
pageSize = 12
}
if pageSize > 100 {
pageSize = 100
}
totalPages := (total + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
page := pageReq.Page
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * pageSize
end := start + pageSize
if end > total {
end = total
}
pageUsers := filtered[start:end]
stats := UserPageStats{TotalUsers: len(users)}
for _, user := range users {
if strings.HasPrefix(user.PasswordHash, "invite/") {
stats.PendingInvites++
} else if user.Disabled {
stats.DisabledUsers++
} else {
stats.ActiveUsers++
}
}
rows := make([]UserRow, len(pageUsers))
for i, user := range pageUsers {
role := ""
tagNames := make([]string, 0, len(user.TagIDs))
for _, tagID := range user.TagIDs {
if tag, ok := tagMap[tagID]; ok {
tagNames = append(tagNames, tag.Name)
if tag.Permissions.AdminAccess && role == "" {
role = tag.Name
} else if role == "" {
role = tag.Name
}
}
}
if role == "" {
role = "user"
}
plan := "standard"
for _, tagID := range user.TagIDs {
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, "admin") {
plan = "unlimited"
break
}
}
isInvite := strings.HasPrefix(user.PasswordHash, "invite/")
status := userStatus(user.Disabled)
if isInvite {
status = "pending"
}
rows[i] = UserRow{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Status: status,
Role: role,
TagIDs: user.TagIDs,
Tags: strings.Join(tagNames, ", "),
Plan: plan,
PolicySummary: "system default",
BoxCount: 0,
APIKeyCount: 0,
CreatedAt: formatTime(user.CreatedAt),
LastSeen: "-",
Disabled: user.Disabled,
IsCurrent: false,
IsInvite: isInvite,
}
}
return UserPage{
Rows: rows,
Page: page,
PageSize: pageSize,
Total: total,
HasPrev: page > 1,
HasNext: page < totalPages,
PrevPage: page - 1,
NextPage: page + 1,
TotalPages: totalPages,
Stats: stats,
}, nil
}
func userStatus(disabled bool) string {
if disabled {
return "disabled"
}
return "active"
}
func formatTime(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.UTC().Format("2006-01-02 15:04")
}
func (store *Store) BulkSetUsersDisabled(ids []string, disabled bool) error {
return store.db.Update(func(txn *badger.Txn) error {
for _, id := range ids {
var user User
if err := getJSON(txn, userKey(id), &user); err != nil {
if errors.Is(err, ErrNotFound) {
continue
}
return err
}
user.Disabled = disabled
user.UpdatedAt = time.Now().UTC()
if err := putJSON(txn, userKey(id), user); err != nil {
return err
}
}
return nil
})
}
func (store *Store) RevokeUserSessions(userID string) error {
tokens, err := store.sessionTokensForUser(userID)
if err != nil {
return err
}
return store.db.Update(func(txn *badger.Txn) error {
for _, token := range tokens {
if err := txn.Delete(sessionKey(token)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
}
return nil
})
}
func (store *Store) BulkRevokeUserSessions(ids []string) error {
for _, id := range ids {
if err := store.RevokeUserSessions(id); err != nil {
return err
}
}
return nil
}
func (store *Store) sessionTokensForUser(userID string) ([]string, error) {
tokens := []string{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("session/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var session Session
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &session)
}); err != nil {
continue
}
if session.UserID == userID {
tokens = append(tokens, session.Token)
}
}
return nil
})
return tokens, err
}
func (store *Store) CountAdminUsers(adminTagID string) (int, error) {
users, err := store.ListUsers()
if err != nil {
return 0, err
}
count := 0
for _, user := range users {
if user.Disabled {
continue
}
for _, tagID := range user.TagIDs {
if tagID == adminTagID {
count++
break
}
}
}
return count, nil
}
func (store *Store) CreateUserWithoutPassword(username string, email string, tagIDs []string) (User, error) {
hash, err := helpers.RandomHexID(32)
if err != nil {
return User{}, err
}
user := User{
Username: username,
Email: email,
PasswordHash: "invite/" + hash,
TagIDs: uniqueStrings(tagIDs),
Disabled: true,
}
if err := store.CreateUser(&user); err != nil {
return User{}, err
}
return user, nil
}
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
return store.GetUser(id)
}
func putJSON(txn *badger.Txn, key []byte, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return txn.Set(key, data)
}
func getJSON(txn *badger.Txn, key []byte, value any) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
return json.Unmarshal(data, value)
})
}
func keyExists(txn *badger.Txn, key []byte) (bool, error) {
_, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return false, nil
}
return err == nil, err
}
func settingKey(name string) []byte {
return []byte("setting/" + strings.TrimSpace(name))
}
func userKey(id string) []byte {
return []byte("user/" + strings.TrimSpace(id))
}
func usernameKey(username string) []byte {
return []byte("user_by_name/" + normalizeIndex(username))
}
func emailKey(email string) []byte {
return []byte("user_by_email/" + normalizeIndex(email))
}
func normalizeIndex(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}

221
lib/metastore/tags.go Normal file
View File

@@ -0,0 +1,221 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func AdminPermissions() TagPermissions {
unlimited := int64(0)
return TagPermissions{
UploadAllowed: true,
MaxFileSizeBytes: &unlimited,
MaxBoxSizeBytes: &unlimited,
OneTimeDownloadAllowed: true,
ZipDownloadAllowed: true,
RenewableAllowed: true,
AdminAccess: true,
AdminUsersView: true,
AdminUsersManage: true,
AdminSettingsManage: true,
AdminBoxesView: true,
}
}
func (store *Store) EnsureAdminTag() (Tag, error) {
tag, ok, err := store.GetTagByName(AdminTagName)
if err != nil {
return Tag{}, err
}
if ok {
tag.Protected = true
tag.Permissions = AdminPermissions()
tag.Description = "Built-in administrator permissions"
if err := store.UpdateTag(tag); err != nil {
return Tag{}, err
}
return tag, nil
}
tag = Tag{
Name: AdminTagName,
Description: "Built-in administrator permissions",
Protected: true,
Permissions: AdminPermissions(),
}
if err := store.CreateTag(&tag); err != nil {
return Tag{}, err
}
return tag, nil
}
func (store *Store) CreateTag(tag *Tag) error {
if tag == nil {
return fmt.Errorf("%w: tag cannot be nil", ErrInvalid)
}
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if tag.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
tag.ID = id
}
tag.CreatedAt = now
tag.UpdatedAt = now
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := putJSON(txn, tagKey(tag.ID), tag); err != nil {
return err
}
return txn.Set(tagNameKey(tag.Name), []byte(tag.ID))
})
}
func (store *Store) UpdateTag(tag Tag) error {
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.ID == "" {
return fmt.Errorf("%w: tag id cannot be empty", ErrInvalid)
}
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
tag.UpdatedAt = time.Now().UTC()
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
var existing Tag
if err := getJSON(txn, tagKey(tag.ID), &existing); err != nil {
return err
}
if normalizeIndex(existing.Name) != normalizeIndex(tag.Name) {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := txn.Delete(tagNameKey(existing.Name)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(tagNameKey(tag.Name), []byte(tag.ID)); err != nil {
return err
}
}
if existing.Protected {
tag.Protected = true
}
if tag.Name == AdminTagName {
tag.Protected = true
tag.Permissions = AdminPermissions()
}
return putJSON(txn, tagKey(tag.ID), tag)
})
}
func (store *Store) GetTag(id string) (Tag, bool, error) {
var tag Tag
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, tagKey(id), &tag)
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
return tag, err == nil, err
}
func (store *Store) GetTagByName(name string) (Tag, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(tagNameKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
if err != nil {
return Tag{}, false, err
}
return store.GetTag(id)
}
func (store *Store) ListTags() ([]Tag, error) {
tags := []Tag{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("tag/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var tag Tag
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &tag)
}); err != nil {
return err
}
tags = append(tags, tag)
}
return nil
})
return tags, err
}
func (store *Store) TagsByID(ids []string) ([]Tag, error) {
tags := make([]Tag, 0, len(ids))
for _, id := range ids {
tag, ok, err := store.GetTag(id)
if err != nil {
return nil, err
}
if ok {
tags = append(tags, tag)
}
}
return tags, nil
}
func normalizeTagPermissions(perms *TagPermissions) {
if perms == nil {
return
}
perms.AllowedExpirySeconds = uniqueInt64s(perms.AllowedExpirySeconds)
}
func tagKey(id string) []byte {
return []byte("tag/" + strings.TrimSpace(id))
}
func tagNameKey(name string) []byte {
return []byte("tag_by_name/" + normalizeIndex(name))
}

View File

@@ -41,19 +41,28 @@ type BoxFile struct {
}
type BoxManifest struct {
Files []BoxFile `json:"files"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RetentionKey string `json:"retention_key"`
RetentionLabel string `json:"retention_label"`
RetentionSecs int64 `json:"retention_seconds"`
PasswordSalt string `json:"password_salt,omitempty"`
PasswordHash string `json:"password_hash,omitempty"`
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
DisableZip bool `json:"disable_zip,omitempty"`
OneTimeDownload bool `json:"one_time_download,omitempty"`
Consumed bool `json:"consumed,omitempty"`
Files []BoxFile `json:"files"`
OwnerID string `json:"owner_id,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
Activity []BoxActivity `json:"activity,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
RetentionKey string `json:"retention_key"`
RetentionLabel string `json:"retention_label"`
RetentionSecs int64 `json:"retention_seconds"`
PasswordSalt string `json:"password_salt,omitempty"`
PasswordHash string `json:"password_hash,omitempty"`
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
DisableZip bool `json:"disable_zip,omitempty"`
OneTimeDownload bool `json:"one_time_download,omitempty"`
Consumed bool `json:"consumed,omitempty"`
}
type BoxActivity struct {
At time.Time `json:"at"`
Message string `json:"message"`
Actor string `json:"actor,omitempty"`
}
type BoxSummary struct {

View File

@@ -0,0 +1,386 @@
package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type AlertPageView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Filters AlertFiltersView
Stats AlertStatsView
Alerts []AlertRowView
SelectedAlert *AlertRowView
Groups []string
CanManageAlerts bool
}
type AlertFiltersView struct {
Query string
Severity string
Status string
Group string
Sort string
}
type AlertStatsView struct {
Open int
Acknowledged int
Closed int
High int
Medium int
Low int
}
type AlertRowView struct {
ID string
Title string
Description string
Severity string
Status string
Code string
Trace string
Group string
MetadataPretty string
CreatedAt string
UpdatedAt string
}
func (app *App) handleAccountAlerts(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.HTML(http.StatusOK, "account_alerts.html", page)
}
func (app *App) handleAccountAlertAcknowledge(ctx *gin.Context) {
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
return app.AcknowledgeAlert(ctx, actor, id)
})
}
func (app *App) handleAccountAlertClose(ctx *gin.Context) {
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
return app.CloseAlert(ctx, actor, id)
})
}
func (app *App) handleAccountAlertBulkAcknowledge(ctx *gin.Context) {
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
return app.BulkAcknowledgeAlerts(ctx, actor, ids)
})
}
func (app *App) handleAccountAlertBulkClose(ctx *gin.Context) {
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
return app.BulkCloseAlerts(ctx, actor, ids)
})
}
func (app *App) handleAccountAlertsExport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.Header("Content-Disposition", `attachment; filename="warpbox-alerts.json"`)
ctx.JSON(http.StatusOK, gin.H{"alerts": page.Alerts, "filters": page.Filters, "stats": page.Stats})
}
func (app *App) handleAccountAlertAction(ctx *gin.Context, action func(metastore.User, string) error) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := action(actor, ctx.Param("id")); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
}
func (app *App) handleAccountAlertBulkAction(ctx *gin.Context, action func(metastore.User, []string) error) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := action(actor, ctx.PostFormArray("alert_ids")); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
}
func (app *App) CreateAlert(ctx *gin.Context, actor metastore.User, input metastore.AlertInput) (metastore.Alert, error) {
if err := app.requireAlertManage(ctx); err != nil {
return metastore.Alert{}, err
}
if input.CreatedBy == "" {
input.CreatedBy = actor.Username
}
return app.store.CreateAlert(input)
}
func (app *App) ListAlerts(ctx *gin.Context, actor metastore.User, filters metastore.AlertFilters) (AlertPageView, error) {
if err := app.requireAlertView(ctx); err != nil {
return AlertPageView{}, err
}
alerts, err := app.store.ListAlerts(filters)
if err != nil {
return AlertPageView{}, err
}
rows := make([]AlertRowView, 0, len(alerts))
stats := AlertStatsView{}
groupSet := map[string]bool{}
for _, alert := range alerts {
row := alertRowView(alert)
rows = append(rows, row)
groupSet[row.Group] = true
switch alert.Status {
case metastore.AlertStatusAcknowledged:
stats.Acknowledged++
case metastore.AlertStatusClosed:
stats.Closed++
default:
stats.Open++
}
switch alert.Severity {
case metastore.AlertSeverityHigh:
stats.High++
case metastore.AlertSeverityMedium:
stats.Medium++
default:
stats.Low++
}
}
groups := make([]string, 0, len(groupSet))
for group := range groupSet {
groups = append(groups, group)
}
if len(groups) == 0 {
groups = []string{"system"}
}
nav := app.accountNavView(ctx, "alerts")
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
var selected *AlertRowView
if len(rows) > 0 {
selected = &rows[0]
}
return AlertPageView{
PageTitle: "WarpBox Alerts",
WindowTitle: "WarpBox Alerts",
WindowIcon: "!",
PageScripts: []string{"/static/js/account-alerts.js"},
AccountNav: nav,
CSRFToken: app.currentCSRFToken(ctx),
Filters: AlertFiltersView{Query: filters.Query, Severity: filters.Severity, Status: filters.Status, Group: filters.Group, Sort: filters.Sort},
Stats: stats,
Alerts: rows,
SelectedAlert: selected,
Groups: groups,
CanManageAlerts: currentAccountPermissions(ctx).AdminAccess,
}, nil
}
func (app *App) AcknowledgeAlert(ctx *gin.Context, actor metastore.User, id string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
return app.store.AcknowledgeAlert(id)
}
func (app *App) CloseAlert(ctx *gin.Context, actor metastore.User, id string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
return app.store.CloseAlert(id)
}
func (app *App) BulkAcknowledgeAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
for _, id := range uniqueNonEmpty(ids) {
if err := app.store.AcknowledgeAlert(id); err != nil {
return err
}
}
return nil
}
func (app *App) BulkCloseAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
for _, id := range uniqueNonEmpty(ids) {
if err := app.store.CloseAlert(id); err != nil {
return err
}
}
return nil
}
func (app *App) EmitSystemAlert(code string, severity string, title string, description string, trace string, metadata any) error {
raw, err := json.Marshal(metadata)
if err != nil {
log.Printf("alert metadata marshal failed: %v", err)
return err
}
_, err = app.store.CreateAlert(metastore.AlertInput{
Title: title,
Description: description,
Severity: severity,
Code: code,
Trace: trace,
Metadata: raw,
CreatedBy: "system",
})
if err != nil {
log.Printf("alert persistence failed: %v", err)
}
return err
}
func (app *App) requireAlertView(ctx *gin.Context) error {
if !currentAccountPermissions(ctx).AdminAccess {
return fmt.Errorf("permission denied")
}
return nil
}
func (app *App) requireAlertManage(ctx *gin.Context) error {
if !currentAccountPermissions(ctx).AdminAccess {
return fmt.Errorf("permission denied")
}
return nil
}
func accountAlertFiltersFromRequest(ctx *gin.Context) metastore.AlertFilters {
return metastore.AlertFilters{
Query: strings.TrimSpace(ctx.Query("q")),
Severity: emptyAsAll(ctx.Query("severity")),
Status: emptyAsAll(ctx.Query("status")),
Group: emptyAsAll(ctx.Query("group")),
Sort: emptyAsNewest(ctx.Query("sort")),
}
}
func emptyAsAll(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "all"
}
return value
}
func emptyAsNewest(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "newest"
}
return value
}
func alertRowView(alert metastore.Alert) AlertRowView {
return AlertRowView{
ID: alert.ID,
Title: alert.Title,
Description: alert.Description,
Severity: alert.Severity,
Status: alert.Status,
Code: alert.Code,
Trace: alert.Trace,
Group: alertGroupFromTrace(alert.Trace),
MetadataPretty: prettyAlertMetadata(alert.Metadata),
CreatedAt: formatAdminTime(alert.CreatedAt),
UpdatedAt: formatAdminTime(alert.UpdatedAt),
}
}
func prettyAlertMetadata(raw json.RawMessage) string {
if len(raw) == 0 {
return "{}"
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return string(raw)
}
pretty, err := json.MarshalIndent(value, "", " ")
if err != nil {
return string(raw)
}
return string(pretty)
}
func alertGroupFromTrace(trace string) string {
trace = strings.TrimSpace(trace)
if trace == "" {
return "system"
}
before, _, found := strings.Cut(trace, ".")
if !found || before == "" {
return "system"
}
return strings.ToLower(before)
}
func (app *App) openAlertSummary() (int, string) {
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen})
if err != nil {
return 0, "ok"
}
severity := "ok"
for _, alert := range alerts {
if alert.Severity == metastore.AlertSeverityHigh {
return len(alerts), "danger"
}
if alert.Severity == metastore.AlertSeverityMedium {
severity = "warning"
} else if severity == "ok" {
severity = "info"
}
}
return len(alerts), severity
}
func uniqueNonEmpty(values []string) []string {
seen := map[string]bool{}
out := []string{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}

View File

@@ -0,0 +1,155 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"warpbox/lib/metastore"
)
func TestAccountAlertsPageListsAndFiltersAlerts(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
createTestAlert(t, app, "701", metastore.AlertSeverityHigh, "storage.connector.health_failed")
request := httptest.NewRequest(http.MethodGet, "/account/alerts?severity=high", 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 alerts page, got %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, "storage.connector.health_failed") {
t.Fatal("expected high severity alert")
}
if strings.Contains(body, "thumbnail.generate.failed") {
t.Fatal("did not expect medium severity alert in high filter")
}
}
func TestAccountAlertAcknowledgeAndClose(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected acknowledge redirect, got %d", response.Code)
}
updated, ok, err := app.store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if updated.Status != metastore.AlertStatusAcknowledged {
t.Fatalf("expected acknowledged alert, got %s", updated.Status)
}
response = postAlertAction(router, session, "/account/alerts/"+alert.ID+"/close", nil)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected close redirect, got %d", response.Code)
}
updated, ok, err = app.store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if updated.Status != metastore.AlertStatusClosed {
t.Fatalf("expected closed alert, got %s", updated.Status)
}
}
func TestAccountAlertManagePermissionDenied(t *testing.T) {
app, _ := setupAccountTestApp(t)
regular, err := app.store.CreateUserWithPassword("regular-alerts", "regular-alerts@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, regular)
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
if response.Code != http.StatusForbidden {
t.Fatalf("expected permission denied, got %d", response.Code)
}
}
func TestDashboardUsesRealAlertCount(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
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, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "1 alerts") {
t.Fatal("expected dashboard alert chip/count")
}
if !strings.Contains(response.Body.String(), "Thumbnail alert") {
t.Fatal("expected dashboard alert preview")
}
}
func TestAccountAlertsExportJSON(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
request := httptest.NewRequest(http.MethodGet, "/account/alerts/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 payload map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if _, ok := payload["alerts"]; !ok {
t.Fatal("expected alerts export shape")
}
}
func createTestAlert(t *testing.T, app *App, code string, severity string, trace string) metastore.Alert {
t.Helper()
alert, err := app.store.CreateAlert(metastore.AlertInput{
Title: "Thumbnail alert",
Description: "Alert test description.",
Severity: severity,
Code: code,
Trace: trace,
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
CreatedBy: "system",
})
if err != nil {
t.Fatalf("CreateAlert returned error: %v", err)
}
return alert
}
func postAlertAction(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
if values == nil {
values = url.Values{}
}
values.Set("csrf_token", session.CSRFToken)
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.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
}

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

@@ -0,0 +1,194 @@
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)
protected.GET("/alerts", app.handleAccountAlerts)
protected.GET("/alerts/export.json", app.handleAccountAlertsExport)
protected.POST("/alerts/bulk/acknowledge", app.handleAccountAlertBulkAcknowledge)
protected.POST("/alerts/bulk/close", app.handleAccountAlertBulkClose)
protected.POST("/alerts/:id/acknowledge", app.handleAccountAlertAcknowledge)
protected.POST("/alerts/:id/close", app.handleAccountAlertClose)
protected.GET("/boxes", app.handleAccountBoxes)
protected.GET("/boxes/export.csv", app.handleAccountBoxesExport)
protected.POST("/boxes/bulk/expire", app.handleAccountBoxesBulkExpire)
protected.POST("/boxes/bulk/delete", app.handleAccountBoxesBulkDelete)
protected.POST("/boxes/bulk/bump-expiry", app.handleAccountBoxesBulkBumpExpiry)
protected.POST("/boxes/delete-largest", app.handleAccountBoxesDeleteLargest)
protected.GET("/boxes/:id", app.handleAccountBoxManager)
protected.POST("/boxes/:id", app.handleAccountBoxUpdate)
protected.POST("/boxes/:id/extend", app.handleAccountBoxExtend)
protected.POST("/boxes/:id/expire", app.handleAccountBoxExpire)
protected.POST("/boxes/:id/delete", app.handleAccountBoxDelete)
protected.POST("/boxes/:id/password", app.handleAccountBoxPassword)
protected.POST("/boxes/:id/password/remove", app.handleAccountBoxPasswordRemove)
protected.POST("/boxes/:id/files/delete", app.handleAccountBoxFilesDelete)
protected.GET("/users", app.handleAccountUsers)
protected.POST("/users", app.handleAccountUsersPost)
protected.POST("/users/bulk/disable", app.handleAccountUsersBulkDisable)
protected.POST("/users/bulk/enable", app.handleAccountUsersBulkEnable)
protected.POST("/users/bulk/revoke-sessions", app.handleAccountUsersBulkRevokeSessions)
protected.POST("/users/:id/invite/resend", app.handleAccountUsersResendInvite)
protected.GET("/users/:id", app.handleAccountUserEdit)
protected.POST("/users/:id", app.handleAccountUserEditPost)
protected.POST("/users/:id/disable", app.handleAccountUserDisable)
protected.POST("/users/:id/enable", app.handleAccountUserEnable)
protected.POST("/users/:id/password/reset", app.handleAccountUserPasswordReset)
protected.POST("/users/:id/sessions/revoke", app.handleAccountUserRevokeSessions)
}
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
}

View File

@@ -0,0 +1,515 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
type BoxManagerView struct {
PageTitle string
WindowTitle string
WindowIcon string
AccountNav AccountNavView
CSRFToken string
Box BoxManagerSummary
Files []BoxManagerFileRow
Policy BoxActionPolicy
PolicyJSON string
Activity []BoxManagerActivityRow
Error string
}
type BoxManagerSummary struct {
ID string
Owner string
Status string
Storage string
CreatedAt string
ExpiresAt string
Flags string
OpenURL string
DisableZip bool
OneTimeDownload bool
}
type BoxManagerFileRow struct {
ID string
Name string
Size string
Status string
Download string
}
type BoxManagerActivityRow struct {
At string
Message string
Actor string
}
type BoxActionPolicy struct {
CanViewManager bool `json:"can_view_manager"`
CanEditMetadata bool `json:"can_edit_metadata"`
CanEditSharingRules bool `json:"can_edit_sharing_rules"`
CanEditPassword bool `json:"can_edit_password"`
CanDeleteBox bool `json:"can_delete_box"`
CanDeleteFiles bool `json:"can_delete_files"`
CanExtendExpiry bool `json:"can_extend_expiry"`
MaxExtensionSeconds int64 `json:"max_extension_seconds"`
MaxRefreshCount int `json:"max_refresh_count"`
MaxTotalLifetimeSecs int64 `json:"max_total_lifetime_seconds"`
Reasons map[string]string `json:"reasons,omitempty"`
}
type BoxRulesInput struct {
DisableZip bool
OneTimeDownload bool
}
type BoxPasswordInput struct {
Password string
}
func (app *App) handleAccountBoxManager(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
view, err := app.GetBoxManager(ctx, actor, ctx.Param("id"))
if err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
}
func (app *App) handleAccountBoxUpdate(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
input := BoxRulesInput{
DisableZip: ctx.PostForm("disable_zip") == "true",
OneTimeDownload: ctx.PostForm("one_time_download") == "true",
}
if err := app.UpdateBoxRules(ctx, actor, ctx.Param("id"), input); err != nil {
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
}
func (app *App) handleAccountBoxExtend(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
seconds := parsePositiveInt64Default(ctx.PostForm("extend_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
if err := app.ExtendBoxExpiry(ctx, actor, ctx.Param("id"), seconds); err != nil {
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
}
func (app *App) handleAccountBoxExpire(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := app.ExpireBoxNow(ctx, actor, ctx.Param("id")); err != nil {
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
}
func (app *App) handleAccountBoxDelete(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := app.DeleteBox(ctx, actor, ctx.Param("id")); err != nil {
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
}
func (app *App) handleAccountBoxPassword(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := app.SetBoxPassword(ctx, actor, ctx.Param("id"), BoxPasswordInput{Password: ctx.PostForm("password")}); err != nil {
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
}
func (app *App) handleAccountBoxPasswordRemove(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := app.RemoveBoxPassword(ctx, actor, ctx.Param("id")); err != nil {
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
}
func (app *App) handleAccountBoxFilesDelete(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := app.DeleteBoxFiles(ctx, actor, ctx.Param("id"), ctx.PostFormArray("file_ids")); err != nil {
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
}
func (app *App) GetBoxManager(ctx *gin.Context, actor metastore.User, boxID string) (BoxManagerView, error) {
record, manifest, err := app.loadBoxForManager(boxID)
if err != nil {
return BoxManagerView{}, err
}
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
if !policy.CanViewManager {
return BoxManagerView{}, fmt.Errorf(policyReason(policy, "view", "permission denied"))
}
files := make([]BoxManagerFileRow, 0, len(manifest.Files))
for _, file := range boxstore.DecorateFiles(boxID, manifest.Files) {
files = append(files, BoxManagerFileRow{ID: file.ID, Name: file.Name, Size: file.SizeLabel, Status: file.StatusLabel, Download: file.DownloadPath})
}
policyJSON, _ := json.MarshalIndent(policy, "", " ")
nav := app.accountNavView(ctx, "boxes")
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
return BoxManagerView{
PageTitle: "WarpBox Box Manager",
WindowTitle: "WarpBox Box Manager",
WindowIcon: "B",
AccountNav: nav,
CSRFToken: app.currentCSRFToken(ctx),
Box: BoxManagerSummary{
ID: record.ID,
Owner: boxOwnerLabel(record),
Status: boxStatus(record),
Storage: helpers.FormatBytes(record.TotalSize),
CreatedAt: formatAdminTime(record.CreatedAt),
ExpiresAt: formatAdminTime(record.ExpiresAt),
Flags: boxFlags(record),
OpenURL: "/box/" + record.ID,
DisableZip: record.DisableZip,
OneTimeDownload: record.OneTimeDownload,
},
Files: files,
Policy: policy,
PolicyJSON: string(policyJSON),
Activity: boxActivityRows(manifest.Activity),
}, nil
}
func (app *App) UpdateBoxRules(ctx *gin.Context, actor metastore.User, boxID string, input BoxRulesInput) error {
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
if err != nil {
return err
}
if !policy.CanEditSharingRules {
return fmt.Errorf(policyReason(policy, "sharing", "sharing edits disabled"))
}
manifest.DisableZip = input.DisableZip
manifest.OneTimeDownload = input.OneTimeDownload
appendBoxActivity(&manifest, actor.Username, "sharing rules updated")
return app.saveManagedBox(record, manifest)
}
func (app *App) ExtendBoxExpiry(ctx *gin.Context, actor metastore.User, boxID string, amount int64) error {
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
if err != nil {
return err
}
if !policy.CanExtendExpiry {
return fmt.Errorf(policyReason(policy, "extend", "expiry refresh disabled"))
}
if amount <= 0 {
return fmt.Errorf("extension amount must be positive")
}
if policy.MaxExtensionSeconds > 0 && amount > policy.MaxExtensionSeconds {
return fmt.Errorf("extension exceeds maximum single extension")
}
if policy.MaxRefreshCount > 0 && record.RefreshCount >= policy.MaxRefreshCount {
return fmt.Errorf("refresh count limit reached")
}
base := manifest.ExpiresAt
if base.IsZero() || time.Now().UTC().After(base) {
base = time.Now().UTC()
}
next := base.Add(time.Duration(amount) * time.Second)
if policy.MaxTotalLifetimeSecs > 0 && next.After(manifest.CreatedAt.Add(time.Duration(policy.MaxTotalLifetimeSecs)*time.Second)) {
return fmt.Errorf("extension exceeds maximum total lifetime")
}
manifest.ExpiresAt = next
record.RefreshCount++
appendBoxActivity(&manifest, actor.Username, "expiry extended")
return app.saveManagedBox(record, manifest)
}
func (app *App) ExpireBoxNow(ctx *gin.Context, actor metastore.User, boxID string) error {
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
if err != nil {
return err
}
if !policy.CanEditMetadata {
return fmt.Errorf(policyReason(policy, "edit", "edit disabled"))
}
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
appendBoxActivity(&manifest, actor.Username, "box expired")
return app.saveManagedBox(record, manifest)
}
func (app *App) DeleteBox(ctx *gin.Context, actor metastore.User, boxID string) error {
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
if err != nil {
return err
}
_ = manifest
if !policy.CanDeleteBox {
return fmt.Errorf(policyReason(policy, "delete", "delete disabled"))
}
if err := boxstore.DeleteBox(record.ID); err != nil {
return err
}
return app.store.DeleteBoxRecord(record.ID)
}
func (app *App) SetBoxPassword(ctx *gin.Context, actor metastore.User, boxID string, input BoxPasswordInput) error {
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
if err != nil {
return err
}
if !policy.CanEditPassword {
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
}
password := strings.TrimSpace(input.Password)
if password == "" {
return fmt.Errorf("password cannot be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
token, err := helpers.RandomHexID(16)
if err != nil {
return err
}
manifest.PasswordHash = string(hash)
manifest.PasswordHashAlg = "bcrypt"
manifest.AuthToken = token
appendBoxActivity(&manifest, actor.Username, "password set")
return app.saveManagedBox(record, manifest)
}
func (app *App) RemoveBoxPassword(ctx *gin.Context, actor metastore.User, boxID string) error {
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
if err != nil {
return err
}
if !policy.CanEditPassword {
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
}
manifest.PasswordHash = ""
manifest.PasswordHashAlg = ""
manifest.PasswordSalt = ""
manifest.AuthToken = ""
appendBoxActivity(&manifest, actor.Username, "password removed")
return app.saveManagedBox(record, manifest)
}
func (app *App) DeleteBoxFiles(ctx *gin.Context, actor metastore.User, boxID string, fileIDs []string) error {
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
if err != nil {
return err
}
if !policy.CanDeleteFiles {
return fmt.Errorf(policyReason(policy, "files", "file deletion disabled"))
}
fileIDs = uniqueNonEmpty(fileIDs)
if len(fileIDs) == 0 {
return fmt.Errorf("no files selected")
}
remove := map[string]bool{}
for _, id := range fileIDs {
remove[id] = true
}
kept := make([]models.BoxFile, 0, len(manifest.Files))
for _, file := range manifest.Files {
if remove[file.ID] {
if path, ok := boxstore.SafeBoxFilePath(boxID, file.Name); ok {
_ = os.Remove(path)
}
continue
}
kept = append(kept, file)
}
manifest.Files = kept
appendBoxActivity(&manifest, actor.Username, "files deleted")
return app.saveManagedBox(record, manifest)
}
func (app *App) renderBoxManagerError(ctx *gin.Context, actor metastore.User, boxID string, actionErr error) {
view, err := app.GetBoxManager(ctx, actor, boxID)
if err != nil {
ctx.String(http.StatusForbidden, actionErr.Error())
return
}
view.Error = actionErr.Error()
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
}
func (app *App) boxMutationContext(ctx *gin.Context, actor metastore.User, boxID string) (metastore.BoxRecord, models.BoxManifest, BoxActionPolicy, error) {
record, manifest, err := app.loadBoxForManager(boxID)
if err != nil {
return record, manifest, BoxActionPolicy{}, err
}
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
if !policy.CanViewManager {
return record, manifest, policy, fmt.Errorf(policyReason(policy, "view", "permission denied"))
}
return record, manifest, policy, nil
}
func (app *App) loadBoxForManager(boxID string) (metastore.BoxRecord, models.BoxManifest, error) {
if !boxstore.ValidBoxID(boxID) {
return metastore.BoxRecord{}, models.BoxManifest{}, fmt.Errorf("invalid box id")
}
record, ok, err := app.store.GetBoxRecord(boxID)
if err != nil {
return record, models.BoxManifest{}, err
}
if !ok {
return record, models.BoxManifest{}, fmt.Errorf("box not found")
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return record, manifest, err
}
return record, manifest, nil
}
func (app *App) resolveBoxPolicy(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord, manifest models.BoxManifest) BoxActionPolicy {
perms := currentAccountPermissions(ctx)
isAdmin := perms.AdminBoxesView
isOwner := record.OwnerID != "" && record.OwnerID == actor.ID
policy := BoxActionPolicy{
MaxExtensionSeconds: app.config.BoxOwnerMaxRefreshAmountSeconds,
MaxRefreshCount: app.config.BoxOwnerMaxRefreshCount,
MaxTotalLifetimeSecs: app.config.BoxOwnerMaxTotalExpirySeconds,
Reasons: map[string]string{},
}
if isAdmin {
policy.CanViewManager = true
policy.CanEditMetadata = true
policy.CanEditSharingRules = true
policy.CanEditPassword = true
policy.CanDeleteBox = true
policy.CanDeleteFiles = true
policy.CanExtendExpiry = !manifest.OneTimeDownload
return policy
}
if !isOwner {
policy.Reasons["view"] = "not box owner"
return policy
}
if !app.config.BoxOwnerEditEnabled {
policy.Reasons["view"] = "box owner editing disabled"
return policy
}
policy.CanViewManager = true
policy.CanEditMetadata = true
policy.CanEditSharingRules = true
policy.CanDeleteBox = true
policy.CanDeleteFiles = true
if app.config.BoxOwnerPasswordEditEnabled {
policy.CanEditPassword = true
} else {
policy.Reasons["password"] = "password editing disabled by policy"
}
if !app.config.BoxOwnerRefreshEnabled {
policy.Reasons["extend"] = "refresh disabled by policy"
} else if manifest.OneTimeDownload {
policy.Reasons["extend"] = "one-time boxes cannot be refreshed"
} else if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
policy.Reasons["extend"] = "refresh count limit reached"
} else {
policy.CanExtendExpiry = true
}
return policy
}
func (app *App) saveManagedBox(record metastore.BoxRecord, manifest models.BoxManifest) error {
if err := boxstore.WriteManifest(record.ID, manifest); err != nil {
return err
}
next := boxRecordFromManifest(record.ID, manifest)
next.RefreshCount = record.RefreshCount
return app.store.UpsertBoxRecord(next)
}
func appendBoxActivity(manifest *models.BoxManifest, actor string, message string) {
manifest.Activity = append([]models.BoxActivity{{
At: time.Now().UTC(),
Actor: actor,
Message: message,
}}, manifest.Activity...)
if len(manifest.Activity) > 12 {
manifest.Activity = manifest.Activity[:12]
}
}
func boxActivityRows(activity []models.BoxActivity) []BoxManagerActivityRow {
rows := make([]BoxManagerActivityRow, 0, len(activity))
for _, item := range activity {
rows = append(rows, BoxManagerActivityRow{At: formatAdminTime(item.At), Message: item.Message, Actor: item.Actor})
}
if len(rows) == 0 {
rows = append(rows, BoxManagerActivityRow{At: "-", Message: "No box activity yet.", Actor: "system"})
}
return rows
}
func policyReason(policy BoxActionPolicy, key string, fallback string) string {
if policy.Reasons != nil && policy.Reasons[key] != "" {
return policy.Reasons[key]
}
return fallback
}
func boxOwnerLabel(record metastore.BoxRecord) string {
if record.OwnerUsername != "" {
return record.OwnerUsername
}
return "guest"
}

View File

@@ -0,0 +1,219 @@
package server
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"warpbox/lib/boxstore"
"warpbox/lib/metastore"
)
func TestAccountBoxManagerAdminCanViewAndEdit(t *testing.T) {
app, admin := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, admin)
id := "abababababababababababababababab"
createIndexedBox(t, app, id, "", "", 10, false)
response := getAccountBoxManager(router, session, id)
if response.Code != http.StatusOK {
t.Fatalf("expected manager page, got %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "WarpBox Box Manager") {
t.Fatal("expected manager UI")
}
form := url.Values{"disable_zip": []string{"true"}}
response = postAccountBoxForm(router, session, "/account/boxes/"+id, form)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected update redirect, got %d", response.Code)
}
manifest, err := boxstore.ReadManifest(id)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
if !manifest.DisableZip {
t.Fatal("expected sharing rule update")
}
}
func TestAccountBoxManagerOwnerViewAllowedAndDeniedByPolicy(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("owner-view", "owner-view@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc"
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
response := getAccountBoxManager(router, session, id)
if response.Code != http.StatusOK {
t.Fatalf("expected owner manager page, got %d", response.Code)
}
app.config.BoxOwnerEditEnabled = false
response = getAccountBoxManager(router, session, id)
if response.Code != http.StatusForbidden {
t.Fatalf("expected owner denied by policy, got %d", response.Code)
}
}
func TestAccountBoxManagerOwnerRefreshLimits(t *testing.T) {
app, _ := setupAccountTestApp(t)
app.config.BoxOwnerMaxRefreshCount = 1
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
app.config.BoxOwnerMaxTotalExpirySeconds = 7200
user, err := app.store.CreateUserWithPassword("owner-refresh", "owner-refresh@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
if response.Code != http.StatusSeeOther {
t.Fatalf("expected owner refresh success, got %d body=%s", response.Code, response.Body.String())
}
record, ok, err := app.store.GetBoxRecord(id)
if err != nil || !ok {
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
}
if record.RefreshCount != 1 {
t.Fatalf("expected refresh count 1, got %d", record.RefreshCount)
}
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
if response.Code != http.StatusOK {
t.Fatalf("expected refresh count rejection render, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "refresh count") {
t.Fatal("expected refresh count error")
}
}
func TestAccountBoxManagerOwnerRefreshRejectedOverMaxDuration(t *testing.T) {
app, _ := setupAccountTestApp(t)
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
user, err := app.store.CreateUserWithPassword("owner-duration", "owner-duration@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "dededededededededededededededede"
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"120"}})
if response.Code != http.StatusOK {
t.Fatalf("expected max duration rejection render, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "maximum single extension") {
t.Fatal("expected max duration error")
}
}
func TestAccountBoxManagerPasswordSetRemovePermissions(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("owner-pass", "owner-pass@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "efefefefefefefefefefefefefefefef"
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/password", url.Values{"password": []string{"new-secret"}})
if response.Code != http.StatusSeeOther {
t.Fatalf("expected password set redirect, got %d", response.Code)
}
manifest, err := boxstore.ReadManifest(id)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
if manifest.PasswordHash == "" || manifest.AuthToken == "" {
t.Fatal("expected password set")
}
app.config.BoxOwnerPasswordEditEnabled = false
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/password/remove", nil)
if response.Code != http.StatusOK {
t.Fatalf("expected password permission render, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "password editing disabled") {
t.Fatal("expected password permission error")
}
}
func TestAccountBoxManagerFileDeleteAndBoxDeletePermissions(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("owner-delete", "owner-delete@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "fafafafafafafafafafafafafafafafa"
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
manifest, err := boxstore.ReadManifest(id)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
fileID := manifest.Files[0].ID
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/files/delete", url.Values{"file_ids": []string{fileID}})
if response.Code != http.StatusSeeOther {
t.Fatalf("expected file delete redirect, got %d", response.Code)
}
manifest, err = boxstore.ReadManifest(id)
if err != nil {
t.Fatalf("ReadManifest returned error: %v", err)
}
if len(manifest.Files) != 0 {
t.Fatalf("expected file removed, got %#v", manifest.Files)
}
app.config.BoxOwnerEditEnabled = false
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
if response.Code != http.StatusForbidden {
t.Fatalf("expected delete permission denied after policy disabled, got %d", response.Code)
}
app.config.BoxOwnerEditEnabled = true
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected box delete redirect, got %d", response.Code)
}
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
t.Fatalf("expected box directory deleted, stat err=%v", err)
}
}
func getAccountBoxManager(router http.Handler, session metastore.Session, id string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, "/account/boxes/"+id, nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func postAccountBoxForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
if values == nil {
values = url.Values{}
}
values.Set("csrf_token", session.CSRFToken)
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.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
}

454
lib/server/account_boxes.go Normal file
View File

@@ -0,0 +1,454 @@
package server
import (
"bytes"
"encoding/csv"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
type BoxIndexView struct {
PageTitle string
WindowTitle string
WindowIcon string
AccountNav AccountNavView
CSRFToken string
Filters BoxFiltersView
Rows []BoxRowView
Stats BoxIndexStats
Page int
PageSize int
Total int
TotalPages int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
CanManage bool
PolicySummary string
Error string
}
type BoxFiltersView struct {
Query string
Owner string
Status string
Flag string
Sort string
PageSize int
}
type BoxIndexStats struct {
Visible int
Total int
Expired int
Storage string
}
type BoxRowView struct {
ID string
Owner string
Status string
FileCount int
Size string
CreatedAt string
ExpiresAt string
Flags string
Policy string
CanManage bool
ManageURL string
OpenURL string
}
func (app *App) handleAccountBoxes(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
view, err := app.ListBoxes(ctx, actor, boxFiltersFromRequest(ctx), boxPageFromRequest(ctx))
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.HTML(http.StatusOK, "account_boxes.html", view)
}
func (app *App) handleAccountBoxesBulkExpire(ctx *gin.Context) {
app.handleAccountBoxesBulkAction(ctx, app.ExpireBoxes)
}
func (app *App) handleAccountBoxesBulkDelete(ctx *gin.Context) {
app.handleAccountBoxesBulkAction(ctx, app.DeleteBoxes)
}
func (app *App) handleAccountBoxesBulkBumpExpiry(ctx *gin.Context) {
app.handleAccountBoxesBulkAction(ctx, func(ctx *gin.Context, actor metastore.User, ids []string) error {
seconds := parsePositiveInt64Default(ctx.PostForm("bump_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
return app.BumpBoxExpiries(ctx, actor, ids, seconds)
})
}
func (app *App) handleAccountBoxesDeleteLargest(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
filters := boxFiltersFromRequest(ctx)
filters.Sort = "largest"
page := metastore.BoxPageRequest{Page: 1, PageSize: 25}
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
if err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ids := make([]string, 0, 10)
for _, row := range boxPage.Rows {
if len(ids) == 10 {
break
}
ids = append(ids, row.ID)
}
if err := app.DeleteBoxes(ctx, actor, ids); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
}
func (app *App) handleAccountBoxesExport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
page, err := app.visibleBoxRecords(ctx, actor, boxFiltersFromRequest(ctx), metastore.BoxPageRequest{Page: 1, PageSize: 100})
if err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
var buffer bytes.Buffer
writer := csv.NewWriter(&buffer)
_ = writer.Write([]string{"id", "owner", "status", "file_count", "total_size", "created_at", "expires_at", "flags"})
for _, record := range page.Rows {
_ = writer.Write([]string{record.ID, record.OwnerUsername, boxStatus(record), strconv.Itoa(record.FileCount), strconv.FormatInt(record.TotalSize, 10), record.CreatedAt.Format(time.RFC3339), record.ExpiresAt.Format(time.RFC3339), boxFlags(record)})
}
writer.Flush()
ctx.Header("Content-Disposition", `attachment; filename="warpbox-boxes.csv"`)
ctx.Data(http.StatusOK, "text/csv; charset=utf-8", buffer.Bytes())
}
func (app *App) handleAccountBoxesBulkAction(ctx *gin.Context, action func(*gin.Context, metastore.User, []string) error) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := action(ctx, actor, ctx.PostFormArray("box_ids")); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
}
func (app *App) ListBoxes(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (BoxIndexView, error) {
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
if err != nil {
return BoxIndexView{}, err
}
rows := make([]BoxRowView, 0, len(boxPage.Rows))
stats := BoxIndexStats{Visible: len(boxPage.Rows), Total: boxPage.Total}
totalSize := int64(0)
for _, record := range boxPage.Rows {
totalSize += record.TotalSize
if boxExpired(record) {
stats.Expired++
}
rows = append(rows, app.boxRowView(ctx, actor, record))
}
stats.Storage = helpers.FormatBytes(totalSize)
nav := app.accountNavView(ctx, "boxes")
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
return BoxIndexView{
PageTitle: "WarpBox Boxes",
WindowTitle: "WarpBox Boxes",
WindowIcon: "B",
AccountNav: nav,
CSRFToken: app.currentCSRFToken(ctx),
Filters: BoxFiltersView{Query: filters.Query, Owner: filters.Owner, Status: filters.Status, Flag: filters.Flag, Sort: filters.Sort, PageSize: boxPage.PageSize},
Rows: rows,
Stats: stats,
Page: boxPage.Page,
PageSize: boxPage.PageSize,
Total: boxPage.Total,
TotalPages: boxPage.TotalPages,
HasPrev: boxPage.HasPrev,
HasNext: boxPage.HasNext,
PrevURL: boxPageURL(ctx, boxPage.PrevPage),
NextURL: boxPageURL(ctx, boxPage.NextPage),
CanManage: currentAccountPermissions(ctx).AdminBoxesView,
PolicySummary: app.boxPolicySummary(),
}, nil
}
func (app *App) ExpireBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
records, err := app.authorizedBoxRecords(ctx, actor, ids)
if err != nil {
return err
}
now := time.Now().UTC().Add(-time.Second)
for _, record := range records {
manifest, err := boxstore.ReadManifest(record.ID)
if err == nil {
manifest.ExpiresAt = now
_ = boxstore.WriteManifest(record.ID, manifest)
}
record.ExpiresAt = now
if err := app.store.UpsertBoxRecord(record); err != nil {
return err
}
}
return nil
}
func (app *App) DeleteBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
records, err := app.authorizedBoxRecords(ctx, actor, ids)
if err != nil {
return err
}
for _, record := range records {
if err := boxstore.DeleteBox(record.ID); err != nil {
return err
}
if err := app.store.DeleteBoxRecord(record.ID); err != nil {
return err
}
}
return nil
}
func (app *App) BumpBoxExpiries(ctx *gin.Context, actor metastore.User, ids []string, seconds int64) error {
if seconds <= 0 {
return fmt.Errorf("bump expiry requires a positive duration")
}
if !app.config.BoxOwnerRefreshEnabled {
return fmt.Errorf("box owner refresh policy is disabled")
}
if app.config.BoxOwnerMaxRefreshAmountSeconds > 0 && seconds > app.config.BoxOwnerMaxRefreshAmountSeconds {
return fmt.Errorf("bump expiry exceeds maximum refresh amount")
}
records, err := app.authorizedBoxRecords(ctx, actor, ids)
if err != nil {
return err
}
for _, record := range records {
if record.OneTimeDownload {
return fmt.Errorf("one-time boxes cannot be refreshed")
}
if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
return fmt.Errorf("box refresh count limit reached")
}
base := record.ExpiresAt
if base.IsZero() || time.Now().UTC().After(base) {
base = time.Now().UTC()
}
newExpiry := base.Add(time.Duration(seconds) * time.Second)
if app.config.BoxOwnerMaxTotalExpirySeconds > 0 && !record.CreatedAt.IsZero() && newExpiry.After(record.CreatedAt.Add(time.Duration(app.config.BoxOwnerMaxTotalExpirySeconds)*time.Second)) {
return fmt.Errorf("bump expiry exceeds maximum total expiry")
}
manifest, err := boxstore.ReadManifest(record.ID)
if err == nil {
manifest.ExpiresAt = newExpiry
_ = boxstore.WriteManifest(record.ID, manifest)
}
record.ExpiresAt = newExpiry
record.RefreshCount++
if err := app.store.UpsertBoxRecord(record); err != nil {
return err
}
}
return nil
}
func (app *App) visibleBoxRecords(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (metastore.BoxRecordPage, error) {
perms := currentAccountPermissions(ctx)
if !perms.AdminBoxesView {
filters.Owner = actor.ID
}
return app.store.ListBoxRecords(filters, page)
}
func (app *App) authorizedBoxRecords(ctx *gin.Context, actor metastore.User, ids []string) ([]metastore.BoxRecord, error) {
ids = uniqueNonEmpty(ids)
if len(ids) == 0 {
return nil, fmt.Errorf("no boxes selected")
}
perms := currentAccountPermissions(ctx)
records := make([]metastore.BoxRecord, 0, len(ids))
for _, id := range ids {
record, ok, err := app.store.GetBoxRecord(id)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("box %s not found", id)
}
if !perms.AdminBoxesView && record.OwnerID != actor.ID {
return nil, fmt.Errorf("permission denied")
}
if !perms.AdminBoxesView && !app.config.BoxOwnerEditEnabled {
return nil, fmt.Errorf("box owner edit policy is disabled")
}
records = append(records, record)
}
return records, nil
}
func (app *App) boxRowView(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord) BoxRowView {
owner := record.OwnerUsername
if owner == "" {
owner = "guest"
}
return BoxRowView{
ID: record.ID,
Owner: owner,
Status: boxStatus(record),
FileCount: record.FileCount,
Size: helpers.FormatBytes(record.TotalSize),
CreatedAt: formatAdminTime(record.CreatedAt),
ExpiresAt: formatAdminTime(record.ExpiresAt),
Flags: boxFlags(record),
Policy: app.boxRecordPolicy(record),
CanManage: currentAccountPermissions(ctx).AdminBoxesView || record.OwnerID == actor.ID,
ManageURL: "/account/boxes/" + record.ID,
OpenURL: "/box/" + record.ID,
}
}
func (app *App) indexBoxFromManifest(boxID string) {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return
}
_ = app.store.UpsertBoxRecord(boxRecordFromManifest(boxID, manifest))
}
func boxRecordFromManifest(boxID string, manifest models.BoxManifest) metastore.BoxRecord {
total := int64(0)
names := make([]string, 0, len(manifest.Files))
for _, file := range manifest.Files {
total += file.Size
names = append(names, file.Name)
}
return metastore.BoxRecord{
ID: boxID,
OwnerID: manifest.OwnerID,
OwnerUsername: manifest.OwnerUsername,
FileNames: names,
FileCount: len(manifest.Files),
TotalSize: total,
CreatedAt: manifest.CreatedAt,
ExpiresAt: manifest.ExpiresAt,
PasswordProtected: boxstore.IsPasswordProtected(manifest),
OneTimeDownload: manifest.OneTimeDownload,
DisableZip: manifest.DisableZip,
}
}
func boxFiltersFromRequest(ctx *gin.Context) metastore.BoxFilters {
return metastore.BoxFilters{
Query: strings.TrimSpace(ctx.Query("q")),
Owner: emptyAsAll(ctx.Query("owner")),
Status: emptyAsAll(ctx.Query("status")),
Flag: emptyAsAll(ctx.Query("flag")),
Sort: emptyAsNewest(ctx.Query("sort")),
}
}
func boxPageFromRequest(ctx *gin.Context) metastore.BoxPageRequest {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "25"))
return metastore.BoxPageRequest{Page: page, PageSize: pageSize}
}
func boxStatus(record metastore.BoxRecord) string {
if boxExpired(record) {
return "expired"
}
if record.ExpiresAt.IsZero() {
return "pending"
}
return "active"
}
func boxExpired(record metastore.BoxRecord) bool {
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
}
func boxFlags(record metastore.BoxRecord) string {
flags := []string{}
if record.PasswordProtected {
flags = append(flags, "password")
}
if record.OneTimeDownload {
flags = append(flags, "one-time")
}
if record.DisableZip {
flags = append(flags, "zip disabled")
}
if boxExpired(record) {
flags = append(flags, "expired")
}
if len(flags) == 0 {
return "normal"
}
return strings.Join(flags, ", ")
}
func (app *App) boxRecordPolicy(record metastore.BoxRecord) string {
if record.OneTimeDownload {
return "one-time: no refresh"
}
if !app.config.BoxOwnerEditEnabled {
return "owner edits disabled"
}
if !app.config.BoxOwnerRefreshEnabled {
return "editable, no refresh"
}
return fmt.Sprintf("editable, refresh %d/%d", record.RefreshCount, app.config.BoxOwnerMaxRefreshCount)
}
func (app *App) boxPolicySummary() string {
if !app.config.BoxOwnerEditEnabled {
return "Owners cannot edit boxes by default."
}
if !app.config.BoxOwnerRefreshEnabled {
return "Owners can edit boxes but cannot refresh expiry."
}
return fmt.Sprintf("Owners can edit and refresh up to %d times by %s.", app.config.BoxOwnerMaxRefreshCount, formatDurationForSettings(app.config.BoxOwnerMaxRefreshAmountSeconds))
}
func boxPageURL(ctx *gin.Context, page int) string {
query := ctx.Request.URL.Query()
query.Set("page", strconv.Itoa(page))
return "/account/boxes?" + query.Encode()
}
func parsePositiveInt64Default(raw string, fallback int64) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || value <= 0 {
return fallback
}
return value
}

View File

@@ -0,0 +1,220 @@
package server
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"warpbox/lib/boxstore"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
func TestAccountBoxesAdminListsBoxes(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createIndexedBox(t, app, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", "", 10, false)
response := getAccountBoxes(router, session, "/account/boxes")
if response.Code != http.StatusOK {
t.Fatalf("expected boxes page, got %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") {
t.Fatal("expected indexed box in admin list")
}
}
func TestAccountBoxesRegularUserSeesOnlyOwnBoxes(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("box-user", "box-user@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createIndexedBox(t, app, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", user.ID, user.Username, 10, false)
createIndexedBox(t, app, "cccccccccccccccccccccccccccccccc", "other", "other", 20, false)
response := getAccountBoxes(router, session, "/account/boxes")
if response.Code != http.StatusOK {
t.Fatalf("expected boxes page, got %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
t.Fatal("expected own box")
}
if strings.Contains(body, "cccccccccccccccccccccccccccccccc") {
t.Fatal("did not expect other user's box")
}
}
func TestAccountBoxesFiltersSortAndPagination(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createIndexedBox(t, app, "11111111111111111111111111111111", "", "", 10, false)
createIndexedBox(t, app, "22222222222222222222222222222222", "", "", 1000, true)
createIndexedBox(t, app, "33333333333333333333333333333333", "", "", 500, false)
response := getAccountBoxes(router, session, "/account/boxes?flag=password&sort=largest&page_size=25")
if response.Code != http.StatusOK {
t.Fatalf("expected boxes page, got %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "22222222222222222222222222222222") {
t.Fatal("expected password filtered box")
}
if strings.Contains(body, "11111111111111111111111111111111") {
t.Fatal("did not expect unfiltered box")
}
page, err := app.store.ListBoxRecords(metastore.BoxFilters{Sort: "largest"}, metastore.BoxPageRequest{Page: 1, PageSize: 25})
if err != nil {
t.Fatalf("ListBoxRecords returned error: %v", err)
}
if len(page.Rows) != 3 || page.Rows[0].ID != "22222222222222222222222222222222" {
t.Fatalf("expected largest sort first, got %#v", page.Rows)
}
}
func TestAccountBoxesBulkExpireAndDelete(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "dddddddddddddddddddddddddddddddd"
createIndexedBox(t, app, id, "", "", 10, false)
values := url.Values{"box_ids": []string{id}}
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/expire", values)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected expire redirect, got %d", response.Code)
}
record, ok, err := app.store.GetBoxRecord(id)
if err != nil || !ok {
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
}
if record.ExpiresAt.After(time.Now().UTC()) {
t.Fatal("expected box to be expired")
}
response = postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", values)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected delete redirect, got %d", response.Code)
}
if _, ok, err := app.store.GetBoxRecord(id); err != nil || ok {
t.Fatalf("expected deleted record, ok=%v err=%v", ok, err)
}
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
t.Fatalf("expected box directory deleted, stat err=%v", err)
}
}
func TestAccountBoxesBulkDeletePermissionDenied(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("box-limited", "box-limited@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
createIndexedBox(t, app, id, "other", "other", 10, false)
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", url.Values{"box_ids": []string{id}})
if response.Code != http.StatusForbidden {
t.Fatalf("expected permission denied, got %d", response.Code)
}
}
func TestAccountBoxesBumpExpiryPolicyRejection(t *testing.T) {
app, user := setupAccountTestApp(t)
app.config.BoxOwnerRefreshEnabled = false
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "ffffffffffffffffffffffffffffffff"
createIndexedBox(t, app, id, "", "", 10, false)
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/bump-expiry", url.Values{"box_ids": []string{id}, "bump_seconds": []string{"60"}})
if response.Code != http.StatusForbidden {
t.Fatalf("expected policy rejection, got %d", response.Code)
}
}
func TestAccountBoxesDeleteLargest(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
small := "12345123451234512345123451234512"
large := "99999999999999999999999999999999"
createIndexedBox(t, app, small, "", "", 10, false)
createIndexedBox(t, app, large, "", "", 1000, false)
response := postAccountBoxesForm(router, session, "/account/boxes/delete-largest", nil)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected delete-largest redirect, got %d", response.Code)
}
if _, ok, err := app.store.GetBoxRecord(large); err != nil || ok {
t.Fatalf("expected largest deleted, ok=%v err=%v", ok, err)
}
}
func createIndexedBox(t *testing.T, app *App, id string, ownerID string, ownerUsername string, size int64, password bool) {
t.Helper()
if err := os.MkdirAll(boxstore.BoxPath(id), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
filename := "file-" + id[:4] + ".txt"
if err := os.WriteFile(filepath.Join(boxstore.BoxPath(id), filename), []byte(strings.Repeat("x", int(size))), 0644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
manifest := models.BoxManifest{
OwnerID: ownerID,
OwnerUsername: ownerUsername,
Files: []models.BoxFile{{
ID: "abcdabcdabcdabcd",
Name: filename,
Size: size,
Status: models.FileStatusReady,
}},
CreatedAt: time.Now().UTC().Add(-time.Duration(size) * time.Second),
ExpiresAt: time.Now().UTC().Add(time.Hour),
RetentionSecs: 3600,
}
if password {
manifest.PasswordHash = "hash"
manifest.AuthToken = "token"
}
if err := boxstore.WriteManifest(id, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
if err := app.store.UpsertBoxRecord(boxRecordFromManifest(id, manifest)); err != nil {
t.Fatalf("UpsertBoxRecord returned error: %v", err)
}
}
func getAccountBoxes(router http.Handler, session metastore.Session, path string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func postAccountBoxesForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
if values == nil {
values = url.Values{}
}
values.Set("csrf_token", session.CSRFToken)
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.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
}

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

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

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

@@ -0,0 +1,253 @@
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),
}
alertPreview := []accountAlertPreviewRow{}
if perms.AdminAccess {
stats.AlertCount, nav.AlertSeverity = app.openAlertSummary()
nav.AlertCount = stats.AlertCount
alertPreview = app.accountDashboardAlertPreview()
}
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: alertPreview,
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 (app *App) accountDashboardAlertPreview() []accountAlertPreviewRow {
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen, Sort: "severity"})
if err != nil {
return nil
}
rows := make([]accountAlertPreviewRow, 0, minInt(len(alerts), 6))
for _, alert := range alerts {
if len(rows) == 6 {
break
}
rows = append(rows, accountAlertPreviewRow{
Severity: alert.Severity,
Title: alert.Title,
Detail: alert.Description,
})
}
return rows
}
func accountPlaceholderActivity(actor metastore.User, ctx *gin.Context) []accountActivityRow {
now := time.Now().UTC()
if value, ok := ctx.Get("accountSession"); ok {
if session, ok := value.(metastore.Session); ok {
now = session.CreatedAt
}
}
return []accountActivityRow{
{
Time: formatAdminTime(now),
Title: "Signed in",
Meta: actor.Username + " opened the account dashboard.",
},
{
Time: "pending",
Title: "Audit log not implemented",
Meta: "Recent account activity will use the audit model in a later pass.",
},
}
}
func accountBoxFlags(expired bool, oneTime bool, passwordProtected bool) string {
flags := []string{}
if expired {
flags = append(flags, "expired")
}
if oneTime {
flags = append(flags, "one-time")
}
if passwordProtected {
flags = append(flags, "password")
}
if len(flags) == 0 {
return "normal"
}
out := flags[0]
for _, flag := range flags[1:] {
out += ", " + flag
}
return out
}
func enabledLabel(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
func boolSeverity(enabled bool) string {
if enabled {
return "ok"
}
return "warn"
}
func minInt(a int, b int) int {
if a < b {
return a
}
return b
}

View File

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

View File

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

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

@@ -0,0 +1,858 @@
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
}
func TestUsersPagePermissionDeniedForNoPerms(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("viewer", "viewer@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/users", 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)
}
if !strings.Contains(response.Body.String(), "Permission denied") {
t.Fatal("expected permission denied message")
}
}
func TestUsersPageLoadsForAdmin(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/users", 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 users page to load, got %d", response.Code)
}
body := response.Body.String()
for _, text := range []string{"WarpBox Users", "Create or Invite", "Total users"} {
if !strings.Contains(body, text) {
t.Fatalf("expected users page body to contain %q", text)
}
}
}
func TestUsersPageListFilters(t *testing.T) {
app, user := setupAccountTestApp(t)
_, err := app.store.CreateUserWithPassword("beta", "beta@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/users?q=beta", 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 users page to load, got %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "beta") {
t.Fatal("expected filtered list to contain beta")
}
if !strings.Contains(body, "1 matching user(s)") {
t.Fatalf("expected 1 matching user for beta filter, got body: %s", body)
}
}
func TestUserCreation(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)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("action", "create")
form.Set("mode", "create")
form.Set("username", "newuser")
form.Set("email", "new@example.test")
form.Set("password", "password123")
request := httptest.NewRequest(http.MethodPost, "/account/users", 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect after create, got %d", response.Code)
}
created, ok, err := app.store.GetUserByUsername("newuser")
if err != nil || !ok {
t.Fatal("expected newuser to exist")
}
if created.Disabled {
t.Fatal("expected newuser to be active")
}
}
func TestUserInviteCreation(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)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("action", "create")
form.Set("mode", "invite")
form.Set("username", "invited")
form.Set("email", "invited@example.test")
request := httptest.NewRequest(http.MethodPost, "/account/users", 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect after invite, got %d", response.Code)
}
created, ok, err := app.store.GetUserByUsername("invited")
if err != nil || !ok {
t.Fatal("expected invited user to exist")
}
if !created.Disabled {
t.Fatal("expected invited user to be disabled")
}
if !strings.HasPrefix(created.PasswordHash, "invite/") {
t.Fatal("expected invited user to have invite prefix")
}
}
func TestBulkDisableRejectsSelf(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)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("selected_ids", user.ID)
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
location := response.Header().Get("Location")
if !strings.Contains(location, "cannot disable yourself") && !strings.Contains(location, "error=") {
t.Fatalf("expected self-disable rejection, got location %q", location)
}
}
func TestBulkDisableProtectsFinalAdmin(t *testing.T) {
app, user := setupAccountTestApp(t)
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
if err != nil || !ok || adminTag.ID == "" {
t.Fatal("expected admin tag")
}
second, err := app.store.CreateUserWithPassword("admin2", "admin2@example.test", "secret", []string{adminTag.ID})
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
// Admin tries to disable the other admin (not self): should work since self remains.
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("selected_ids", second.ID)
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected success redirect, got %d", response.Code)
}
location := response.Header().Get("Location")
if !strings.Contains(location, "user(s) disabled") {
t.Fatalf("expected success message, got %q", location)
}
// Verify admin2 is disabled, admin1 still active
disabledUser, ok, _ := app.store.GetUserByUsername("admin2")
if !ok || !disabledUser.Disabled {
t.Fatal("expected admin2 to be disabled")
}
adminUser, ok, _ := app.store.GetUserByUsername("admin")
if !ok || adminUser.Disabled {
t.Fatal("expected admin to remain active")
}
// Now try to disable the only remaining admin (self): should be rejected
form2 := url.Values{}
form2.Set("csrf_token", session.CSRFToken)
form2.Set("selected_ids", user.ID)
req2 := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form2.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req2.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
resp2 := httptest.NewRecorder()
router.ServeHTTP(resp2, req2)
if resp2.Code != http.StatusSeeOther {
t.Fatalf("expected redirect for self-disable rejection, got %d", resp2.Code)
}
loc2 := resp2.Header().Get("Location")
if !strings.Contains(loc2, "cannot disable yourself") {
t.Fatalf("expected self-disable rejection, got %q", loc2)
}
}
func TestUserEditPagePermissionDenied(t *testing.T) {
app, _ := setupAccountTestApp(t)
regular, err := app.store.CreateUserWithPassword("viewer2", "viewer2@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(regular.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/account/users/"+regular.ID, 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 403, got %d", response.Code)
}
}
func TestUserEditPageLoadsForAdmin(t *testing.T) {
app, admin := setupAccountTestApp(t)
target, err := app.store.CreateUserWithPassword("edittarget", "edittarget@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/account/users/"+target.ID, 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 200, got %d", response.Code)
}
body := response.Body.String()
for _, text := range []string{"edittarget", "Access rights", "Limits", "Setting overrides", "Resolved policy"} {
if !strings.Contains(body, text) {
t.Fatalf("expected body to contain %q", text)
}
}
}
func TestUserEditProfileUpdate(t *testing.T) {
app, admin := setupAccountTestApp(t)
target, err := app.store.CreateUserWithPassword("origname", "orig@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("username", "newname")
form.Set("email", "new@example.test")
form.Set("admin_note", "test note")
form.Set("state", "active")
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
updated, ok, _ := app.store.GetUser(target.ID)
if !ok {
t.Fatal("user not found after update")
}
if updated.Username != "newname" {
t.Fatalf("expected username newname, got %q", updated.Username)
}
if updated.AdminNote != "test note" {
t.Fatalf("expected admin note, got %q", updated.AdminNote)
}
}
func TestUserEditAccessRightsUpdate(t *testing.T) {
app, admin := setupAccountTestApp(t)
target, err := app.store.CreateUserWithPassword("perm_target", "perm@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("username", target.Username)
form.Set("email", target.Email)
form.Set("upload_allowed", "1")
form.Set("zip_download_allowed", "1")
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
updated, ok, _ := app.store.GetUser(target.ID)
if !ok || updated.PermOverrides == nil {
t.Fatal("expected perm overrides to be set")
}
if updated.PermOverrides.UploadAllowed == nil || !*updated.PermOverrides.UploadAllowed {
t.Fatal("expected upload_allowed=true")
}
if updated.PermOverrides.ZipDownloadAllowed == nil || !*updated.PermOverrides.ZipDownloadAllowed {
t.Fatal("expected zip_download_allowed=true")
}
}
func TestUserEditLimitsUpdate(t *testing.T) {
app, admin := setupAccountTestApp(t)
target, err := app.store.CreateUserWithPassword("limits_target", "limits@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("username", target.Username)
form.Set("email", target.Email)
form.Set("max_file_size_bytes", "1073741824")
form.Set("max_expiry_seconds", "86400")
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
updated, ok, _ := app.store.GetUser(target.ID)
if !ok {
t.Fatal("user not found")
}
if updated.MaxFileSizeBytes == nil || *updated.MaxFileSizeBytes != 1073741824 {
t.Fatalf("expected max_file_size_bytes=1073741824, got %v", updated.MaxFileSizeBytes)
}
if updated.MaxExpirySeconds == nil || *updated.MaxExpirySeconds != 86400 {
t.Fatalf("expected max_expiry_seconds=86400, got %v", updated.MaxExpirySeconds)
}
}
func TestUserEditInvalidLimitRejected(t *testing.T) {
app, admin := setupAccountTestApp(t)
target, err := app.store.CreateUserWithPassword("badlimit", "badlimit@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("username", target.Username)
form.Set("email", target.Email)
form.Set("max_file_size_bytes", "notanumber")
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
location := response.Header().Get("Location")
if !strings.Contains(location, "error=") {
t.Fatalf("expected error redirect, got %q", location)
}
}
func TestUserEditSelfDisableRejected(t *testing.T) {
app, admin := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("username", admin.Username)
form.Set("email", admin.Email)
form.Set("state", "disabled")
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
location := response.Header().Get("Location")
if !strings.Contains(location, "error=") {
t.Fatalf("expected error redirect, got %q", location)
}
unchanged, _, _ := app.store.GetUser(admin.ID)
if unchanged.Disabled {
t.Fatal("admin should not have been disabled")
}
}
func TestUserEditLastAdminProtected(t *testing.T) {
app, admin := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
// try to remove admin tag from the only admin via is_admin=0 (unchecked)
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("username", admin.Username)
form.Set("email", admin.Email)
// is_admin NOT set → wantsAdmin=false → should be blocked
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
location := response.Header().Get("Location")
if !strings.Contains(location, "error=") {
t.Fatalf("expected error for last-admin removal, got %q", location)
}
}
func TestUserEditPasswordReset(t *testing.T) {
app, admin := setupAccountTestApp(t)
target, err := app.store.CreateUserWithPassword("resetme", "resetme@example.test", "oldpass", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/password/reset", 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
location := response.Header().Get("Location")
if !strings.Contains(location, "success=") {
t.Fatalf("expected success redirect, got %q", location)
}
updated, _, _ := app.store.GetUser(target.ID)
if metastore.VerifyPassword(updated.PasswordHash, "oldpass") {
t.Fatal("old password should no longer work after reset")
}
}
func TestUserEditRevokeSessions(t *testing.T) {
app, admin := setupAccountTestApp(t)
target, err := app.store.CreateUserWithPassword("revokeme", "revokeme@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword: %v", err)
}
targetSession, err := app.store.CreateSession(target.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
router := setupAccountTestRouter(t, app)
adminSession, err := app.store.CreateSession(admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
form := url.Values{}
form.Set("csrf_token", adminSession.CSRFToken)
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/sessions/revoke", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: adminSession.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
_, stillValid, _ := app.store.GetSession(targetSession.Token)
if stillValid {
t.Fatal("target session should have been revoked")
}
}
func TestBulkRevokeSessions(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)
}
other, err := app.store.CreateUserWithPassword("other", "other@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
if _, err := app.store.CreateSession(other.ID, time.Hour); err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set("selected_ids", other.ID)
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/revoke-sessions", 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)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
location := response.Header().Get("Location")
if !strings.Contains(location, "Sessions revoked") {
t.Fatalf("expected success message, got %q", location)
}
}

View File

@@ -0,0 +1,557 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type UserEditView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Target metastore.User
Tags []metastore.Tag
AdminTagID string
IsAdmin bool
IsPending bool
Status string
Perms metastore.EffectivePermissions
PolicyJSON string
CanManage bool
IsSelf bool
Error string
Success string
// precomputed display values
TagNames string
CreatedAtStr string
UpdatedAtStr string
MaxFileSizeStr string
MaxBoxSizeStr string
MaxExpiryStr string
// precomputed perm override checkbox states
Check map[string]bool
}
func (app *App) handleAccountUserEdit(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersView && !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
view, err := app.buildUserEditView(ctx, actor, userID, perms.AdminUsersManage, "", "")
if err != nil {
ctx.String(http.StatusNotFound, "User not found")
return
}
ctx.HTML(http.StatusOK, "account_user_edit.html", view)
}
func (app *App) handleAccountUserEditPost(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
redirectUserEdit(ctx, userID, "User not found.", "")
return
}
// profile
username := strings.TrimSpace(ctx.PostForm("username"))
email := strings.TrimSpace(ctx.PostForm("email"))
adminNote := strings.TrimSpace(ctx.PostForm("admin_note"))
if username == "" {
redirectUserEdit(ctx, userID, "Username is required.", "")
return
}
// state (cannot change pending via this field)
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
if !isPending {
stateVal := ctx.PostForm("state")
switch stateVal {
case "disabled":
if target.ID == actor.ID {
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
return
}
if !target.Disabled {
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
redirectUserEdit(ctx, userID, err.Error(), "")
return
}
}
target.Disabled = true
case "active":
target.Disabled = false
}
}
// admin tag toggle
adminTag, adminTagOK, err := app.store.GetTagByName(metastore.AdminTagName)
if err != nil {
redirectUserEdit(ctx, userID, "Could not verify admin tag.", "")
return
}
wantsAdmin := ctx.PostForm("is_admin") == "1"
if adminTagOK {
hasAdmin := containsString(target.TagIDs, adminTag.ID)
if wantsAdmin && !hasAdmin {
target.TagIDs = append(target.TagIDs, adminTag.ID)
} else if !wantsAdmin && hasAdmin {
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
redirectUserEdit(ctx, userID, "Cannot remove admin from the last active administrator.", "")
return
}
target.TagIDs = removeString(target.TagIDs, adminTag.ID)
}
}
// per-user permission overrides
target.PermOverrides = &metastore.UserPermOverrides{
UploadAllowed: boolPtr(ctx.PostForm("upload_allowed") == "1"),
ManageOwnBoxes: boolPtr(ctx.PostForm("manage_own_boxes") == "1"),
ZipDownloadAllowed: boolPtr(ctx.PostForm("zip_download_allowed") == "1"),
OneTimeDownloadAllowed: boolPtr(ctx.PostForm("one_time_download_allowed") == "1"),
RenewableAllowed: boolPtr(ctx.PostForm("renewable_allowed") == "1"),
AllowPasswordProtected: boolPtr(ctx.PostForm("allow_password_protected") == "1"),
RenewOnAccess: boolPtr(ctx.PostForm("renew_on_access") == "1"),
RenewOnDownload: boolPtr(ctx.PostForm("renew_on_download") == "1"),
AllowOwnerBoxEditing: boolPtr(ctx.PostForm("allow_owner_box_editing") == "1"),
}
// limits
if raw := ctx.PostForm("max_file_size_bytes"); raw != "" {
v, err := parseOptionalInt64(raw)
if err != nil {
redirectUserEdit(ctx, userID, "Max file size: "+err.Error(), "")
return
}
target.MaxFileSizeBytes = v
} else {
target.MaxFileSizeBytes = nil
}
if raw := ctx.PostForm("max_box_size_bytes"); raw != "" {
v, err := parseOptionalInt64(raw)
if err != nil {
redirectUserEdit(ctx, userID, "Max box size: "+err.Error(), "")
return
}
target.MaxBoxSizeBytes = v
} else {
target.MaxBoxSizeBytes = nil
}
if raw := ctx.PostForm("max_expiry_seconds"); raw != "" {
v, err := parseOptionalInt64(raw)
if err != nil {
redirectUserEdit(ctx, userID, "Max expiry: "+err.Error(), "")
return
}
target.MaxExpirySeconds = v
} else {
target.MaxExpirySeconds = nil
}
target.Username = username
target.Email = email
target.AdminNote = adminNote
if err := app.store.UpdateUser(target); err != nil {
redirectUserEdit(ctx, userID, "Could not save user: "+err.Error(), "")
return
}
redirectUserEdit(ctx, userID, "", "User saved.")
}
func (app *App) handleAccountUserDisable(ctx *gin.Context) {
app.handleAccountUserSetDisabled(ctx, true)
}
func (app *App) handleAccountUserEnable(ctx *gin.Context) {
app.handleAccountUserSetDisabled(ctx, false)
}
func (app *App) handleAccountUserSetDisabled(ctx *gin.Context, disabled bool) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
if userID == actor.ID && disabled {
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
return
}
if disabled {
if err := app.checkLastAdminDisable([]string{userID}); err != nil {
redirectUserEdit(ctx, userID, err.Error(), "")
return
}
}
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
redirectUserEdit(ctx, userID, "User not found.", "")
return
}
target.Disabled = disabled
if err := app.store.UpdateUser(target); err != nil {
redirectUserEdit(ctx, userID, "Could not update user.", "")
return
}
action := "enabled"
if disabled {
action = "disabled"
}
redirectUserEdit(ctx, userID, "", "User "+action+".")
}
func (app *App) handleAccountUserPasswordReset(ctx *gin.Context) {
_, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
redirectUserEdit(ctx, userID, "User not found.", "")
return
}
newPassword := randomPassword()
hash, err := metastore.HashPassword(newPassword)
if err != nil {
redirectUserEdit(ctx, userID, "Could not hash password.", "")
return
}
target.PasswordHash = hash
target.Disabled = false
if err := app.store.UpdateUser(target); err != nil {
redirectUserEdit(ctx, userID, "Could not reset password.", "")
return
}
redirectUserEdit(ctx, userID, "", "Password reset. Temporary password: "+newPassword)
}
func (app *App) handleAccountUserRevokeSessions(ctx *gin.Context) {
_, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
if err := app.store.RevokeUserSessions(userID); err != nil {
redirectUserEdit(ctx, userID, "Could not revoke sessions.", "")
return
}
redirectUserEdit(ctx, userID, "", "All sessions revoked.")
}
func (app *App) buildUserEditView(ctx *gin.Context, actor metastore.User, userID string, canManage bool, errMsg string, successMsg string) (UserEditView, error) {
target, found, err := app.store.GetUser(userID)
if err != nil || !found {
return UserEditView{}, err
}
tags, _ := app.store.ListTags()
adminTagID := ""
for _, t := range tags {
if t.Name == metastore.AdminTagName {
adminTagID = t.ID
break
}
}
isAdmin := containsString(target.TagIDs, adminTagID)
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
status := "active"
if isPending {
status = "pending"
} else if target.Disabled {
status = "disabled"
}
effectivePerms, _ := app.permissionsForUser(target)
policyJSON := buildPolicyJSON(target.Username, status, effectivePerms, target.PermOverrides)
// tag names
tagNames := make([]string, 0, len(target.TagIDs))
for _, tagID := range target.TagIDs {
for _, t := range tags {
if t.ID == tagID {
tagNames = append(tagNames, t.Name)
break
}
}
}
// perm override checkboxes
checks := map[string]bool{
"upload_allowed": effectivePerms.UploadAllowed,
"manage_own_boxes": false,
"zip_download_allowed": effectivePerms.ZipDownloadAllowed,
"one_time_download_allowed": effectivePerms.OneTimeDownloadAllowed,
"renewable_allowed": effectivePerms.RenewableAllowed,
"allow_password_protected": false,
"renew_on_access": false,
"renew_on_download": false,
"allow_owner_box_editing": false,
}
if o := target.PermOverrides; o != nil {
if o.UploadAllowed != nil {
checks["upload_allowed"] = *o.UploadAllowed
}
if o.ManageOwnBoxes != nil {
checks["manage_own_boxes"] = *o.ManageOwnBoxes
}
if o.ZipDownloadAllowed != nil {
checks["zip_download_allowed"] = *o.ZipDownloadAllowed
}
if o.OneTimeDownloadAllowed != nil {
checks["one_time_download_allowed"] = *o.OneTimeDownloadAllowed
}
if o.RenewableAllowed != nil {
checks["renewable_allowed"] = *o.RenewableAllowed
}
if o.AllowPasswordProtected != nil {
checks["allow_password_protected"] = *o.AllowPasswordProtected
}
if o.RenewOnAccess != nil {
checks["renew_on_access"] = *o.RenewOnAccess
}
if o.RenewOnDownload != nil {
checks["renew_on_download"] = *o.RenewOnDownload
}
if o.AllowOwnerBoxEditing != nil {
checks["allow_owner_box_editing"] = *o.AllowOwnerBoxEditing
}
}
return UserEditView{
PageTitle: "Edit User — " + target.Username,
WindowTitle: "User Edit — " + target.Username,
WindowIcon: "U",
PageScripts: []string{"/static/js/account-user-edit.js"},
AccountNav: app.accountNavView(ctx, "users"),
CSRFToken: app.currentCSRFToken(ctx),
Target: target,
Tags: tags,
AdminTagID: adminTagID,
IsAdmin: isAdmin,
IsPending: isPending,
Status: status,
Perms: effectivePerms,
PolicyJSON: policyJSON,
CanManage: canManage,
IsSelf: actor.ID == target.ID,
Error: errMsg,
Success: successMsg,
TagNames: strings.Join(tagNames, ", "),
CreatedAtStr: formatAdminTime(target.CreatedAt),
UpdatedAtStr: formatAdminTime(target.UpdatedAt),
MaxFileSizeStr: int64PtrStr(target.MaxFileSizeBytes),
MaxBoxSizeStr: int64PtrStr(target.MaxBoxSizeBytes),
MaxExpiryStr: int64PtrStr(target.MaxExpirySeconds),
Check: checks,
}, nil
}
func buildPolicyJSON(username string, status string, perms metastore.EffectivePermissions, overrides *metastore.UserPermOverrides) string {
type permMap struct {
BoxesCreate bool `json:"boxes.create"`
ManageOwn bool `json:"boxes.manage_own"`
RefreshOwn bool `json:"boxes.refresh_own"`
DownloadsZip bool `json:"downloads.zip"`
DownloadsOneTime bool `json:"downloads.one_time"`
AdminAccess bool `json:"admin.access"`
AdminUsers bool `json:"admin.users.manage"`
AdminSettings bool `json:"admin.settings.manage"`
}
type limitsMap struct {
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
MaxExpirySeconds int64 `json:"max_expiry_seconds"`
}
type overridesMap struct {
AllowPassword bool `json:"allow_password_protected"`
RenewOnAccess bool `json:"renew_on_access"`
RenewOnDownload bool `json:"renew_on_download"`
AllowOwnerEdit bool `json:"allow_owner_box_editing"`
}
type preview struct {
User string `json:"user"`
Status string `json:"status"`
Permissions permMap `json:"permissions"`
Limits limitsMap `json:"limits"`
Overrides overridesMap `json:"overrides"`
}
manageOwn := false
allowPwd := false
renewAccess := false
renewDownload := false
allowOwnerEdit := false
if overrides != nil {
if overrides.ManageOwnBoxes != nil {
manageOwn = *overrides.ManageOwnBoxes
}
if overrides.AllowPasswordProtected != nil {
allowPwd = *overrides.AllowPasswordProtected
}
if overrides.RenewOnAccess != nil {
renewAccess = *overrides.RenewOnAccess
}
if overrides.RenewOnDownload != nil {
renewDownload = *overrides.RenewOnDownload
}
if overrides.AllowOwnerBoxEditing != nil {
allowOwnerEdit = *overrides.AllowOwnerBoxEditing
}
}
p := preview{
User: username,
Status: status,
Permissions: permMap{
BoxesCreate: perms.UploadAllowed,
ManageOwn: manageOwn,
RefreshOwn: perms.RenewableAllowed,
DownloadsZip: perms.ZipDownloadAllowed,
DownloadsOneTime: perms.OneTimeDownloadAllowed,
AdminAccess: perms.AdminAccess,
AdminUsers: perms.AdminUsersManage,
AdminSettings: perms.AdminSettingsManage,
},
Limits: limitsMap{
MaxFileSizeBytes: perms.MaxFileSizeBytes,
MaxBoxSizeBytes: perms.MaxBoxSizeBytes,
MaxExpirySeconds: perms.MaxExpirySeconds,
},
Overrides: overridesMap{
AllowPassword: allowPwd,
RenewOnAccess: renewAccess,
RenewOnDownload: renewDownload,
AllowOwnerEdit: allowOwnerEdit,
},
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return "{}"
}
return string(data)
}
func (app *App) checkLastAdminDisable(ids []string) error {
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
if err != nil || !ok {
return nil
}
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
if err != nil {
return err
}
removing := 0
for _, id := range ids {
u, found, _ := app.store.GetUser(id)
if found && !u.Disabled && containsString(u.TagIDs, adminTag.ID) {
removing++
}
}
if adminCount-removing < 1 {
return fmt.Errorf("cannot remove the last active administrator")
}
return nil
}
func int64PtrStr(v *int64) string {
if v == nil {
return ""
}
return fmt.Sprintf("%d", *v)
}
func redirectUserEdit(ctx *gin.Context, userID string, errMsg string, successMsg string) {
base := "/account/users/" + userID
if errMsg != "" {
ctx.Redirect(http.StatusSeeOther, base+"?error="+errMsg)
} else if successMsg != "" {
ctx.Redirect(http.StatusSeeOther, base+"?success="+successMsg)
} else {
ctx.Redirect(http.StatusSeeOther, base)
}
}
func containsString(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func removeString(slice []string, s string) []string {
out := make([]string, 0, len(slice))
for _, v := range slice {
if v != s {
out = append(out, v)
}
}
return out
}
func boolPtr(b bool) *bool {
return &b
}

195
lib/server/admin_auth.go Normal file
View File

@@ -0,0 +1,195 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
const adminSessionCookie = "warpbox_admin_session"
func (app *App) handleAdminLogin(ctx *gin.Context) {
if app.isAdminSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/admin")
return
}
app.renderAdminLogin(ctx, "")
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAdminLogin(ctx, "Administrator 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.renderAdminLogin(ctx, "The username or password was not accepted.")
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
if !perms.AdminAccess {
app.renderAdminLogin(ctx, "This user does not have administrator access.")
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(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) requireAdminSession(ctx *gin.Context) {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/admin/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, "/admin/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil || !perms.AdminAccess {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Set("adminUser", user)
ctx.Set("adminPerms", perms)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(adminSessionCookie)
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
}
perms, err := app.permissionsForUser(user)
return err == nil && perms.AdminAccess
}
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
tags, err := app.store.TagsByID(user.TagIDs)
if err != nil {
return metastore.EffectivePermissions{}, err
}
return metastore.ResolveUserPermissions(app.config, user, tags), nil
}
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
value, ok := ctx.Get("adminPerms")
if !ok {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok || !allowed(perms) {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
return true
}
func (app *App) currentAdminUsername(ctx *gin.Context) string {
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user.Username
}
}
return ""
}
func (app *App) currentCSRFToken(ctx *gin.Context) string {
if value, ok := ctx.Get("adminCSRFToken"); ok {
if token, ok := value.(string); ok {
return token
}
}
return ""
}
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func noStoreAdminHeaders(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.Header("Pragma", "no-cache")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Next()
}
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
switch ctx.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
token := ctx.PostForm("csrf_token")
if token == "" {
token = ctx.GetHeader("X-CSRF-Token")
}
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}
func subtleConstantTimeEqual(a string, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

63
lib/server/admin_boxes.go Normal file
View File

@@ -0,0 +1,63 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
type adminBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Expired bool
OneTimeDownload bool
PasswordProtected bool
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
return
}
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list boxes")
return
}
rows := make([]adminBoxRow, 0, len(summaries))
totalSize := int64(0)
expiredCount := 0
for _, summary := range summaries {
totalSize += summary.TotalSize
if summary.Expired {
expiredCount++
}
rows = append(rows, adminBoxRow{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Expired: summary.Expired,
OneTimeDownload: summary.OneTimeDownload,
PasswordProtected: summary.PasswordProtected,
})
}
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
"AdminSection": "boxes",
"CurrentUser": app.currentAdminUsername(ctx),
"Boxes": rows,
"TotalBoxes": len(rows),
"TotalStorage": helpers.FormatBytes(totalSize),
"ExpiredBoxes": expiredCount,
})
}

View File

@@ -0,0 +1,14 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) handleAdminDashboard(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "admin.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
})
}

View File

@@ -0,0 +1,73 @@
package server
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
func parseOptionalInt64(raw string) (*int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, errors.New("must be an integer")
}
if value < 0 {
return nil, errors.New("must be at least 0")
}
return &value, nil
}
func parseCSVInt64(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
}
if value < 0 {
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
}
values = append(values, value)
}
return values, nil
}
func optionalInt64Label(value *int64) string {
if value == nil {
return "-"
}
return strconv.FormatInt(*value, 10)
}
func joinInt64s(values []int64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ", ")
}
func formatAdminTime(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.Local().Format("2006-01-02 15:04:05")
}

View File

@@ -0,0 +1,37 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders)
admin.GET("/login", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account/login")
})
admin.POST("/login", app.handleAdminLoginPost)
admin.GET("", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account")
})
admin.GET("/", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account")
})
protected := admin.Group("")
protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account/users")
})
protected.POST("/users", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account/users")
})
protected.GET("/tags", app.handleAdminTags)
protected.POST("/tags", app.handleAdminTagsPost)
protected.GET("/settings", app.handleAdminSettings)
protected.POST("/settings", app.handleAdminSettingsPost)
}

View File

@@ -0,0 +1,58 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
func (app *App) handleAdminSettings(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
app.renderAdminSettings(ctx, "")
}
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
if !app.config.AllowAdminSettingsOverride {
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
return
}
for _, def := range config.EditableDefinitions() {
value := ctx.PostForm(def.Key)
if def.Type == config.SettingTypeBool {
value = "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
}
if err := app.config.ApplyOverride(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
if err := app.store.SetSetting(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
}
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
}
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"AdminSection": "settings",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage,
})
}

122
lib/server/admin_tags.go Normal file
View File

@@ -0,0 +1,122 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type adminTagRow struct {
ID string
Name string
Description string
Protected bool
AdminAccess bool
UploadAllowed bool
ZipDownloadAllowed bool
OneTimeDownloadAllowed bool
RenewableAllowed bool
MaxFileSizeBytes string
MaxBoxSizeBytes string
AllowedExpirySeconds string
}
func (app *App) handleAdminTags(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminTags(ctx, "")
}
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
perms, err := parseTagPermissions(ctx)
if err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
tag := metastore.Tag{
Name: ctx.PostForm("name"),
Description: ctx.PostForm("description"),
Permissions: perms,
}
if err := app.store.CreateTag(&tag); err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
}
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
sort.Slice(tags, func(i int, j int) bool {
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
})
rows := make([]adminTagRow, 0, len(tags))
for _, tag := range tags {
rows = append(rows, adminTagRow{
ID: tag.ID,
Name: tag.Name,
Description: tag.Description,
Protected: tag.Protected,
AdminAccess: tag.Permissions.AdminAccess,
UploadAllowed: tag.Permissions.UploadAllowed,
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
RenewableAllowed: tag.Permissions.RenewableAllowed,
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
})
}
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
"AdminSection": "tags",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Tags": rows,
"Error": errorMessage,
})
}
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
}
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
}
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
if err != nil {
return metastore.TagPermissions{}, err
}
return metastore.TagPermissions{
UploadAllowed: checkbox(ctx, "upload_allowed"),
AllowedExpirySeconds: expirySeconds,
MaxFileSizeBytes: maxFileSize,
MaxBoxSizeBytes: maxBoxSize,
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
AdminAccess: checkbox(ctx, "admin_access"),
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
}, nil
}
func checkbox(ctx *gin.Context, name string) bool {
return ctx.PostForm(name) == "true"
}

393
lib/server/admin_users.go Normal file
View File

@@ -0,0 +1,393 @@
package server
import (
"crypto/rand"
"fmt"
"math/big"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
const defaultUserPageSize = 12
type UsersIndexView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Filters UserFiltersView
Rows []metastore.UserRow
Stats metastore.UserPageStats
Page int
PageSize int
Total int
TotalPages int
HasPrev bool
HasNext bool
CanManage bool
Tags []metastore.Tag
Error string
Success string
}
type UserFiltersView struct {
Query string
Status string
Role string
Sort string
PageSize int
}
func (app *App) handleAccountUsers(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersView && !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
filters := userFiltersFromRequest(ctx)
pageReq := userPageFromRequest(ctx)
userPage, err := app.store.ListUsersPaginated(filters, pageReq)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list users")
return
}
currentID := actor.ID
for i := range userPage.Rows {
if userPage.Rows[i].ID == currentID {
userPage.Rows[i].IsCurrent = true
}
}
tags, _ := app.store.ListTags()
view := UsersIndexView{
PageTitle: "WarpBox Users",
WindowTitle: "WarpBox Users",
WindowIcon: "U",
PageScripts: []string{"/static/js/account-users.js"},
AccountNav: app.accountNavView(ctx, "users"),
CSRFToken: app.currentCSRFToken(ctx),
Filters: UserFiltersView{
Query: filters.Query,
Status: filters.Status,
Role: filters.Role,
Sort: filters.Sort,
PageSize: pageReq.PageSize,
},
Rows: userPage.Rows,
Stats: userPage.Stats,
Page: userPage.Page,
PageSize: userPage.PageSize,
Total: userPage.Total,
TotalPages: userPage.TotalPages,
HasPrev: userPage.HasPrev,
HasNext: userPage.HasNext,
CanManage: perms.AdminUsersManage,
Tags: tags,
Error: ctx.Query("error"),
Success: ctx.Query("success"),
}
ctx.HTML(http.StatusOK, "account_users.html", view)
}
func (app *App) handleAccountUsersPost(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
action := ctx.PostForm("action")
switch action {
case "create":
app.handleAccountUsersCreate(ctx, actor)
default:
redirectAccountUsers(ctx, "Unknown action", "")
}
}
func (app *App) handleAccountUsersCreate(ctx *gin.Context, _ metastore.User) {
username := strings.TrimSpace(ctx.PostForm("username"))
email := strings.TrimSpace(ctx.PostForm("email"))
mode := strings.TrimSpace(ctx.PostForm("mode"))
role := strings.TrimSpace(ctx.PostForm("role"))
if username == "" {
redirectAccountUsers(ctx, "Username is required.", "")
return
}
if email == "" {
redirectAccountUsers(ctx, "Email is required.", "")
return
}
var tagIDs []string
if role != "" && role != "all" {
tag, ok, err := app.store.GetTagByName(role)
if err != nil {
redirectAccountUsers(ctx, "Could not look up role.", "")
return
}
if ok {
tagIDs = append(tagIDs, tag.ID)
}
}
switch mode {
case "invite":
user, err := app.store.CreateUserWithoutPassword(username, email, tagIDs)
if err != nil {
redirectAccountUsers(ctx, err.Error(), "")
return
}
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
msg := fmt.Sprintf("Invite user created. Setup link: %s (Email delivery not yet implemented.)", inviteLink)
redirectAccountUsers(ctx, "", msg)
case "create":
password := strings.TrimSpace(ctx.PostForm("password"))
autoGen := false
if password == "" {
password = randomPassword()
autoGen = true
}
user, err := app.store.CreateUserWithPassword(username, email, password, tagIDs)
if err != nil {
redirectAccountUsers(ctx, err.Error(), "")
return
}
msg := fmt.Sprintf("User %s created.", user.Username)
if autoGen {
msg = fmt.Sprintf("User %s created. Temporary password: %s", user.Username, password)
}
redirectAccountUsers(ctx, "", msg)
default:
redirectAccountUsers(ctx, "Select create or invite mode.", "")
}
}
func (app *App) handleAccountUsersBulkDisable(ctx *gin.Context) {
app.handleAccountUsersBulkSetDisabled(ctx, true)
}
func (app *App) handleAccountUsersBulkEnable(ctx *gin.Context) {
app.handleAccountUsersBulkSetDisabled(ctx, false)
}
func (app *App) handleAccountUsersBulkSetDisabled(ctx *gin.Context, disabled bool) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ids := parseSelectedIDs(ctx)
if len(ids) == 0 {
redirectAccountUsers(ctx, "No users selected.", "")
return
}
for _, id := range ids {
if id == actor.ID {
redirectAccountUsers(ctx, "You cannot disable yourself.", "")
return
}
}
if disabled {
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
if err != nil || !ok {
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
return
}
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
if err != nil {
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
return
}
disableAdminCount := 0
for _, id := range ids {
user, ok, err := app.store.GetUser(id)
if err != nil || !ok {
continue
}
if !user.Disabled {
for _, tagID := range user.TagIDs {
if tagID == adminTag.ID {
disableAdminCount++
break
}
}
}
}
if adminCount-disableAdminCount < 1 {
redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "")
return
}
}
if err := app.store.BulkSetUsersDisabled(ids, disabled); err != nil {
redirectAccountUsers(ctx, "Could not update users.", "")
return
}
action := "disabled"
if !disabled {
action = "enabled"
}
redirectAccountUsers(ctx, "", fmt.Sprintf("%d user(s) %s.", len(ids), action))
}
func (app *App) handleAccountUsersBulkRevokeSessions(ctx *gin.Context) {
_, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ids := parseSelectedIDs(ctx)
if len(ids) == 0 {
redirectAccountUsers(ctx, "No users selected.", "")
return
}
if err := app.store.BulkRevokeUserSessions(ids); err != nil {
redirectAccountUsers(ctx, "Could not revoke sessions.", "")
return
}
redirectAccountUsers(ctx, "", fmt.Sprintf("Sessions revoked for %d user(s).", len(ids)))
}
func (app *App) handleAccountUsersResendInvite(ctx *gin.Context) {
_, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
perms := currentAccountPermissions(ctx)
if !perms.AdminUsersManage {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
userID := strings.TrimSpace(ctx.Param("id"))
if userID == "" {
redirectAccountUsers(ctx, "User ID is required.", "")
return
}
user, ok, err := app.store.GetUser(userID)
if err != nil || !ok {
redirectAccountUsers(ctx, "User not found.", "")
return
}
if !strings.HasPrefix(user.PasswordHash, "invite/") {
redirectAccountUsers(ctx, "This user is not a pending invite.", "")
return
}
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
redirectAccountUsers(ctx, "", fmt.Sprintf("Invite link: %s (Email delivery not yet implemented.)", inviteLink))
}
func userFiltersFromRequest(ctx *gin.Context) metastore.UserFilters {
return metastore.UserFilters{
Query: strings.TrimSpace(ctx.Query("q")),
Status: strings.TrimSpace(ctx.Query("status")),
Role: strings.TrimSpace(ctx.Query("role")),
Sort: strings.TrimSpace(ctx.Query("sort")),
}
}
func userPageFromRequest(ctx *gin.Context) metastore.UserPageRequest {
page := 1
if p, err := strconv.Atoi(ctx.Query("page")); err == nil && p > 0 {
page = p
}
pageSize := defaultUserPageSize
if ps, err := strconv.Atoi(ctx.Query("page_size")); err == nil && ps >= 1 && ps <= 100 {
pageSize = ps
}
return metastore.UserPageRequest{
Page: page,
PageSize: pageSize,
}
}
func parseSelectedIDs(ctx *gin.Context) []string {
raw := ctx.PostForm("selected_ids")
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
for _, part := range parts {
id := strings.TrimSpace(part)
if id != "" {
ids = append(ids, id)
}
}
return ids
}
func redirectAccountUsers(ctx *gin.Context, errorMsg string, successMsg string) {
redirectURL := "/account/users"
if errorMsg != "" {
redirectURL = "/account/users?error=" + errorMsg
} else if successMsg != "" {
redirectURL = "/account/users?success=" + successMsg
}
ctx.Redirect(http.StatusSeeOther, redirectURL)
}
func randomPassword() string {
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
result := make([]byte, 16)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
if err != nil {
return "changeme123"
}
result[i] = charset[n.Int64()]
}
return string(result)
}

View File

@@ -1,12 +1,17 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
@@ -35,3 +40,40 @@ func TestValidateManifestFileUploadRejectsExpiredBox(t *testing.T) {
t.Fatalf("expected expired box to be deleted, stat err=%v", err)
}
}
func TestAdminProtectedPostRequiresCSRF(t *testing.T) {
gin.SetMode(gin.TestMode)
store, err := metastore.Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
adminTag, err := store.EnsureAdminTag()
if err != nil {
t.Fatalf("EnsureAdminTag returned error: %v", err)
}
user, err := store.CreateUserWithPassword("admin", "", "secret", []string{adminTag.ID})
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
session, err := store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
app := &App{config: &config.Config{}, store: store}
router := gin.New()
router.POST("/admin/test", app.requireAdminSession, func(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPost, "/admin/test", nil)
request.AddCookie(&http.Cookie{Name: adminSessionCookie, 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)
}
}

View File

@@ -1,6 +1,7 @@
package server
import (
"fmt"
"time"
"github.com/gin-contrib/gzip"
@@ -8,11 +9,14 @@ import (
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/routing"
)
type App struct {
config *config.Config
config *config.Config
store *metastore.Store
adminLoginEnabled bool
}
func Run(addr string) error {
@@ -26,7 +30,31 @@ func Run(addr string) error {
applyBoxstoreRuntimeConfig(cfg)
app := &App{config: cfg}
store, err := metastore.Open(cfg.DBDir)
if err != nil {
return fmt.Errorf("open metadata database: %w", err)
}
defer store.Close()
overrides, err := store.ListSettings()
if err != nil {
return fmt.Errorf("load settings overrides: %w", err)
}
if err := cfg.ApplyOverrides(overrides); err != nil {
return fmt.Errorf("apply settings overrides: %w", err)
}
applyBoxstoreRuntimeConfig(cfg)
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil {
return fmt.Errorf("bootstrap admin metadata: %w", err)
}
app := &App{
config: cfg,
store: store,
adminLoginEnabled: bootstrap.AdminLoginEnabled,
}
router := gin.Default()
router.LoadHTMLGlob("templates/*.html")
@@ -46,6 +74,8 @@ func Run(addr string) error {
DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload,
})
app.registerAccountRoutes(router)
app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static")

View File

@@ -45,6 +45,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
}
@@ -80,6 +81,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
}
@@ -116,6 +118,7 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"file": file})
}
@@ -231,6 +234,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
savedFiles = append(savedFiles, savedFile)
}
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
}

2034
static/css/account.css Normal file

File diff suppressed because it is too large Load Diff

132
static/css/admin.css Normal file
View File

@@ -0,0 +1,132 @@
body {
min-height: 100vh;
}
.admin-window {
width: min(1120px, calc(100vw - 32px));
margin: 32px auto;
}
.admin-panel {
display: grid;
gap: 16px;
padding: 16px;
background-color: #ffffff;
background-image:
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
}
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.admin-spacer {
flex: 1;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.admin-link {
min-height: 88px;
padding: 12px;
color: inherit;
text-decoration: none;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
}
.admin-link strong,
.admin-link span {
display: block;
}
.admin-link span {
margin-top: 8px;
}
.admin-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.admin-table th,
.admin-table td {
padding: 8px;
border: 1px solid #808080;
text-align: left;
vertical-align: top;
}
.admin-form {
display: grid;
gap: 10px;
}
.admin-form-row {
display: grid;
gap: 4px;
}
.admin-form-row input,
.admin-form-row textarea,
.admin-form-row select {
width: 100%;
min-height: 24px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-family: inherit;
}
.admin-checks {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
}
.admin-checks label {
display: flex;
gap: 6px;
align-items: center;
}
.admin-error {
padding: 8px;
border: 1px solid #800;
background: #ffdede;
}
.admin-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.admin-summary span {
padding: 6px 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}

View File

@@ -0,0 +1,16 @@
document.addEventListener("DOMContentLoaded", () => {
const title = document.querySelector("[data-alert-detail-title]");
const description = document.querySelector("[data-alert-detail-description]");
const metadata = document.querySelector("[data-alert-detail-metadata]");
document.querySelectorAll("[data-alert-row]").forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("button, input, a")) return;
document.querySelectorAll("[data-alert-row].is-selected").forEach((item) => item.classList.remove("is-selected"));
row.classList.add("is-selected");
if (title) title.textContent = row.dataset.alertTitle || "";
if (description) description.textContent = row.dataset.alertDescription || "";
if (metadata) metadata.textContent = row.dataset.alertMetadata || "{}";
});
});
});

View File

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

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

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

View File

@@ -0,0 +1,101 @@
(function () {
const form = document.querySelector('[data-ue-form]');
const dirtyIndicator = document.querySelector('[data-ue-dirty]');
const menuItems = Array.from(document.querySelectorAll('.menu-item'));
const toast = document.getElementById('account-toast');
let dirty = false;
let toastTimer = null;
function setDirty(next) {
dirty = next;
if (dirtyIndicator) {
dirtyIndicator.textContent = dirty ? 'Unsaved changes' : 'No unsaved changes';
}
const chip = document.querySelector('[data-dirty-chip]');
if (chip) {
chip.textContent = dirty ? '● unsaved' : '';
}
}
function showToast(msg, type) {
if (!toast) return;
toast.textContent = msg;
toast.className = 'toast is-visible' + (type ? ' toast-' + type : '');
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () {
toast.classList.remove('is-visible');
}, 2400);
}
function closeMenus() {
menuItems.forEach(function (item) { item.classList.remove('is-open'); });
}
menuItems.forEach(function (item) {
const btn = item.querySelector('.menu-button');
if (!btn) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
const open = item.classList.contains('is-open');
closeMenus();
if (!open) item.classList.add('is-open');
});
});
document.addEventListener('click', closeMenus);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeMenus();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
if (form) form.requestSubmit ? form.requestSubmit() : form.submit();
}
});
if (form) {
form.addEventListener('change', function () { setDirty(true); });
form.addEventListener('input', function () { setDirty(true); });
form.addEventListener('submit', function () { setDirty(false); });
}
document.querySelectorAll('[data-ue-command]').forEach(function (el) {
el.addEventListener('click', function () {
closeMenus();
const cmd = el.getAttribute('data-ue-command');
switch (cmd) {
case 'save':
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
break;
case 'discard':
if (dirty && form) {
if (window.confirm('Discard unsaved changes?')) {
setDirty(false);
form.reset();
}
}
break;
case 'reset-password': {
const resetForm = document.querySelector('form[action*="/password/reset"]');
if (resetForm && window.confirm('Reset this user\'s password? A temporary password will be generated and shown.')) {
resetForm.submit();
}
break;
}
default:
showToast('Action: ' + cmd);
}
});
});
// sticky scroll shadow on taskbar
const header = document.querySelector('.top-taskbar');
if (header) {
function updateScroll() {
header.classList.toggle('is-scrolled', window.scrollY > 4);
}
updateScroll();
window.addEventListener('scroll', updateScroll, { passive: true });
}
}());

View File

@@ -0,0 +1,67 @@
(function () {
const masterCheck = document.getElementById('master-check');
const rowChecks = document.querySelectorAll('.row-check');
const bulkForm = document.getElementById('users-bulk-form');
const selectedIdsInput = document.getElementById('bulk-selected-ids');
const selectedCount = document.getElementById('selected-count');
const focusCreateBtn = document.querySelector('[data-users-action="focus-create"]');
const selectVisibleBtns = document.querySelectorAll('[data-users-action="select-visible"]');
function updateSelected() {
const checked = document.querySelectorAll('.row-check:checked');
const ids = Array.from(checked).map(cb => cb.value);
selectedIdsInput.value = ids.join(',');
if (selectedCount) {
selectedCount.textContent = ids.length + ' selected';
}
if (masterCheck) {
const allRowChecks = document.querySelectorAll('.row-check');
masterCheck.checked = allRowChecks.length > 0 && checked.length === allRowChecks.length;
masterCheck.indeterminate = checked.length > 0 && checked.length < allRowChecks.length;
}
}
if (masterCheck) {
masterCheck.addEventListener('change', function () {
document.querySelectorAll('.row-check').forEach(function (cb) {
cb.checked = masterCheck.checked;
});
updateSelected();
});
}
document.addEventListener('change', function (event) {
if (event.target.classList.contains('row-check')) {
updateSelected();
}
});
selectVisibleBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.row-check').forEach(function (cb) {
cb.checked = true;
});
updateSelected();
});
});
if (focusCreateBtn) {
focusCreateBtn.addEventListener('click', function () {
var usernameInput = document.getElementById('users-username');
if (usernameInput) {
usernameInput.scrollIntoView({ behavior: 'smooth' });
setTimeout(function () { usernameInput.focus(); }, 150);
}
});
}
updateSelected();
})();
function setBulkAction(actionUrl) {
var form = document.getElementById('users-bulk-form');
if (form) {
form.action = actionUrl;
}
return true;
}

View File

@@ -0,0 +1,182 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-alerts-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Alerts 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/alerts"><span>R</span><span>Refresh alerts</span><span></span></a>
<a class="menu-action" href="/account/alerts/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">
<a class="menu-action" href="/account/alerts?status=open"><span>O</span><span>Open</span><span></span></a>
<a class="menu-action" href="/account/alerts?severity=high"><span>H</span><span>High severity</span><span></span></a>
<a class="menu-action" href="/account/alerts?sort=severity"><span>S</span><span>Sort by severity</span><span></span></a>
</div>
</div>
</nav>
<div class="alerts-layout account-body-content">
<section class="stats-grid" aria-label="Alert statistics">
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Open</p>
<p class="stat-value">{{ .Stats.Open }}</p>
<p class="stat-note"><span class="stat-note-pill">needs attention</span></p>
</article>
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Acknowledged</p>
<p class="stat-value">{{ .Stats.Acknowledged }}</p>
<p class="stat-note"><span class="stat-note-pill">seen</span></p>
</article>
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Closed</p>
<p class="stat-value">{{ .Stats.Closed }}</p>
<p class="stat-note"><span class="stat-note-pill">done</span></p>
</article>
<article class="stat-card sunken-panel is-danger">
<p class="stat-label">High</p>
<p class="stat-value">{{ .Stats.High }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Medium }} medium</span><span class="stat-note-pill">{{ .Stats.Low }} low</span></p>
</article>
</section>
<form class="alerts-filterbar raised-panel" action="/account/alerts" method="get">
<label class="account-form-row">
<span>Search</span>
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="title, code, trace">
</label>
<label class="account-form-row">
<span>Severity</span>
<select class="account-control" name="severity">
<option value="all" {{ if eq .Filters.Severity "all" }}selected{{ end }}>All</option>
<option value="low" {{ if eq .Filters.Severity "low" }}selected{{ end }}>Low</option>
<option value="medium" {{ if eq .Filters.Severity "medium" }}selected{{ end }}>Medium</option>
<option value="high" {{ if eq .Filters.Severity "high" }}selected{{ end }}>High</option>
</select>
</label>
<label class="account-form-row">
<span>Status</span>
<select class="account-control" name="status">
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
<option value="open" {{ if eq .Filters.Status "open" }}selected{{ end }}>Open</option>
<option value="acknowledged" {{ if eq .Filters.Status "acknowledged" }}selected{{ end }}>Acknowledged</option>
<option value="closed" {{ if eq .Filters.Status "closed" }}selected{{ end }}>Closed</option>
</select>
</label>
<label class="account-form-row">
<span>Group</span>
<select class="account-control" name="group">
<option value="all" {{ if eq .Filters.Group "all" }}selected{{ end }}>All</option>
{{ range .Groups }}
<option value="{{ . }}" {{ if eq $.Filters.Group . }}selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
</label>
<label class="account-form-row">
<span>Sort</span>
<select class="account-control" name="sort">
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
<option value="severity" {{ if eq .Filters.Sort "severity" }}selected{{ end }}>Severity</option>
</select>
</label>
<button class="win98-button" type="submit">Apply</button>
</form>
<section class="alerts-workspace">
<form class="win98-window section-window" action="/account/alerts/bulk/acknowledge" method="post">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">!</span>
<h2>Alert List</h2>
</div>
</div>
<div class="section-body sunken-panel">
{{ template "account_csrf_field" . }}
<div class="scroll-panel alerts-table-scroll">
<table class="account-table alerts-table">
<thead>
<tr>
<th>Select</th>
<th>Severity</th>
<th>Status</th>
<th>Code</th>
<th>Title</th>
<th>Trace</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Alerts }}
<tr data-alert-row data-alert-id="{{ .ID }}" data-alert-title="{{ .Title }}" data-alert-description="{{ .Description }}" data-alert-metadata="{{ .MetadataPretty }}" class="{{ if eq $.SelectedAlert.ID .ID }}is-selected{{ end }}">
<td><input type="checkbox" name="alert_ids" value="{{ .ID }}"></td>
<td><span class="badge is-{{ .Severity }}">{{ .Severity }}</span></td>
<td><span class="badge">{{ .Status }}</span></td>
<td>{{ .Code }}</td>
<td>{{ .Title }}</td>
<td>{{ .Trace }}</td>
<td>{{ .CreatedAt }}</td>
<td>
<div class="box-actions">
{{ if $.CanManageAlerts }}
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/acknowledge">Ack</button>
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/close">Close</button>
{{ end }}
</div>
</td>
</tr>
{{ else }}
<tr><td colspan="8">No alerts found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
{{ if .CanManageAlerts }}
<div class="bulk-actions raised-panel">
<button class="win98-button" type="submit">Acknowledge selected</button>
<button class="win98-button" type="submit" formaction="/account/alerts/bulk/close">Close selected</button>
</div>
{{ end }}
</div>
</form>
<aside class="alerts-detail sunken-panel" aria-label="Alert details">
{{ if .SelectedAlert }}
<div>
<h2 data-alert-detail-title>{{ .SelectedAlert.Title }}</h2>
<p data-alert-detail-description>{{ .SelectedAlert.Description }}</p>
</div>
<pre class="metadata-pre" data-alert-detail-metadata>{{ .SelectedAlert.MetadataPretty }}</pre>
<div class="setting-source">
<span class="badge is-{{ .SelectedAlert.Severity }}">{{ .SelectedAlert.Severity }}</span>
<span class="badge">{{ .SelectedAlert.Status }}</span>
<span class="setting-env">{{ .SelectedAlert.Trace }}</span>
</div>
{{ else }}
<div>
<h2 data-alert-detail-title>No alert selected</h2>
<p data-alert-detail-description>Select an alert row to inspect metadata.</p>
</div>
<pre class="metadata-pre" data-alert-detail-metadata>{}</pre>
{{ end }}
</aside>
</section>
</div>
<footer class="win98-statusbar" aria-label="Alerts status">
<span>alerts</span>
<span>{{ .Stats.Open }} open</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

@@ -0,0 +1,151 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="box-manager-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Box manager 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/boxes"><span>B</span><span>Back to boxes</span><span></span></a>
<a class="menu-action" href="{{ .Box.OpenURL }}"><span>O</span><span>Open shared box</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>
</nav>
<div class="box-manager-layout account-body-content">
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
<section class="stats-grid" aria-label="Box summary">
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Status</p>
<p class="stat-value">{{ .Box.Status }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Box.Flags }}</span></p>
</article>
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Storage</p>
<p class="stat-value">{{ .Box.Storage }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ len .Files }} files</span></p>
</article>
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Expiration</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Box.ExpiresAt }}</span></p>
</article>
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Owner policy</p>
<p class="stat-note"><span class="stat-note-pill">{{ if .Policy.CanEditMetadata }}editable{{ else }}locked{{ end }}</span><span class="stat-note-pill">{{ if .Policy.CanExtendExpiry }}refreshable{{ else }}no refresh{{ end }}</span></p>
</article>
</section>
<section class="box-manager-grid">
<div class="box-manager-main">
<section class="win98-window section-window">
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">I</span><h2 id="box-manager-title">Identity</h2></div></div>
<div class="section-body sunken-panel">
<p><strong>Box:</strong> {{ .Box.ID }}</p>
<p><strong>Owner:</strong> {{ .Box.Owner }}</p>
<p><strong>Created:</strong> {{ .Box.CreatedAt }}</p>
</div>
</section>
<section class="win98-window section-window">
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">S</span><h2>Sharing Rules</h2></div></div>
<form class="section-body sunken-panel account-form" action="/account/boxes/{{ .Box.ID }}" method="post">
{{ template "account_csrf_field" . }}
<label><input type="checkbox" name="disable_zip" value="true" {{ if .Box.DisableZip }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> Disable ZIP downloads</label>
<label><input type="checkbox" name="one_time_download" value="true" {{ if .Box.OneTimeDownload }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> One-time download</label>
<button class="win98-button" type="submit" {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}>Save Rules</button>
</form>
</section>
<section class="win98-window section-window">
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">F</span><h2>Files</h2></div></div>
<form class="section-body sunken-panel" action="/account/boxes/{{ .Box.ID }}/files/delete" method="post">
{{ template "account_csrf_field" . }}
<div class="scroll-panel files-scroll">
<table class="account-table boxes-table">
<thead><tr><th>Select</th><th>Name</th><th>Size</th><th>Status</th><th>Download</th></tr></thead>
<tbody>
{{ range .Files }}
<tr>
<td><input type="checkbox" name="file_ids" value="{{ .ID }}"></td>
<td>{{ .Name }}</td>
<td>{{ .Size }}</td>
<td>{{ .Status }}</td>
<td><a class="tiny-button" href="{{ .Download }}">Open</a></td>
</tr>
{{ else }}
<tr><td colspan="5">No files.</td></tr>
{{ end }}
</tbody>
</table>
</div>
<div class="bulk-actions raised-panel">
<button class="win98-button" type="submit" data-confirm="Delete selected files permanently?" {{ if not .Policy.CanDeleteFiles }}disabled{{ end }}>Delete Files</button>
</div>
</form>
</section>
</div>
<aside class="box-manager-side">
<section class="sunken-panel section-body">
<h2>Expiration</h2>
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/extend" method="post">
{{ template "account_csrf_field" . }}
<label class="account-form-row"><span>Extend seconds</span><input class="account-control" name="extend_seconds" value="{{ .Policy.MaxExtensionSeconds }}" inputmode="numeric"></label>
<button class="win98-button" type="submit" {{ if not .Policy.CanExtendExpiry }}disabled{{ end }}>Extend</button>
</form>
<form action="/account/boxes/{{ .Box.ID }}/expire" method="post">
{{ template "account_csrf_field" . }}
<button class="win98-button" type="submit" data-confirm="Expire this box now?" {{ if not .Policy.CanEditMetadata }}disabled{{ end }}>Expire Now</button>
</form>
</section>
<section class="sunken-panel section-body">
<h2>Password</h2>
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/password" method="post">
{{ template "account_csrf_field" . }}
<label class="account-form-row"><span>New password</span><input class="account-control" name="password" type="password" autocomplete="new-password"></label>
<button class="win98-button" type="submit" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Set Password</button>
</form>
<form action="/account/boxes/{{ .Box.ID }}/password/remove" method="post">
{{ template "account_csrf_field" . }}
<button class="win98-button" type="submit" data-confirm="Remove box password?" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Remove Password</button>
</form>
</section>
<section class="sunken-panel section-body">
<h2>Resolved Policy</h2>
<pre class="metadata-pre policy-pre">{{ .PolicyJSON }}</pre>
</section>
<section class="sunken-panel section-body">
<h2>Box Activity</h2>
<div class="activity-list-compact">
{{ range .Activity }}
<div class="activity-row"><span class="activity-time">{{ .At }}</span><div><p class="activity-title">{{ .Message }}</p><p class="activity-meta">{{ .Actor }}</p></div></div>
{{ end }}
</div>
</section>
<section class="sunken-panel section-body">
<form action="/account/boxes/{{ .Box.ID }}/delete" method="post">
{{ template "account_csrf_field" . }}
<button class="win98-button" type="submit" data-confirm="Delete this box permanently?" {{ if not .Policy.CanDeleteBox }}disabled{{ end }}>Delete Box</button>
</form>
</section>
</aside>
</section>
</div>
<footer class="win98-statusbar" aria-label="Box manager status">
<span>{{ .Box.ID }}</span>
<span>{{ .Box.Status }}</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

@@ -0,0 +1,174 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-boxes-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Boxes 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/boxes"><span>R</span><span>Refresh boxes</span><span></span></a>
<a class="menu-action" href="/account/boxes/export.csv"><span>E</span><span>Export visible CSV</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">
<a class="menu-action" href="/account/boxes?status=active"><span>A</span><span>Active</span><span></span></a>
<a class="menu-action" href="/account/boxes?status=expired"><span>X</span><span>Expired</span><span></span></a>
<a class="menu-action" href="/account/boxes?sort=largest"><span>L</span><span>Largest first</span><span></span></a>
</div>
</div>
</nav>
<div class="boxes-layout account-body-content">
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
<section class="stats-grid" aria-label="Box statistics">
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Visible</p>
<p class="stat-value">{{ .Stats.Visible }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Total }} matching</span></p>
</article>
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Expired</p>
<p class="stat-value">{{ .Stats.Expired }}</p>
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
</article>
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Storage</p>
<p class="stat-value">{{ .Stats.Storage }}</p>
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
</article>
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Policy</p>
<p class="stat-note"><span class="stat-note-pill">{{ .PolicySummary }}</span></p>
</article>
</section>
<form class="boxes-filterbar raised-panel" action="/account/boxes" method="get">
<label class="account-form-row">
<span>Search</span>
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="id, owner, file">
</label>
<label class="account-form-row">
<span>Owner</span>
<input class="account-control" name="owner" value="{{ .Filters.Owner }}" placeholder="all">
</label>
<label class="account-form-row">
<span>Status</span>
<select class="account-control" name="status">
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>Active</option>
<option value="pending" {{ if eq .Filters.Status "pending" }}selected{{ end }}>Pending</option>
<option value="expired" {{ if eq .Filters.Status "expired" }}selected{{ end }}>Expired</option>
</select>
</label>
<label class="account-form-row">
<span>Flag</span>
<select class="account-control" name="flag">
<option value="all" {{ if eq .Filters.Flag "all" }}selected{{ end }}>All</option>
<option value="password" {{ if eq .Filters.Flag "password" }}selected{{ end }}>Password</option>
<option value="one-time" {{ if eq .Filters.Flag "one-time" }}selected{{ end }}>One-time</option>
<option value="zip-disabled" {{ if eq .Filters.Flag "zip-disabled" }}selected{{ end }}>ZIP disabled</option>
<option value="expired" {{ if eq .Filters.Flag "expired" }}selected{{ end }}>Expired</option>
<option value="refreshable" {{ if eq .Filters.Flag "refreshable" }}selected{{ end }}>Refreshable</option>
</select>
</label>
<label class="account-form-row">
<span>Sort</span>
<select class="account-control" name="sort">
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
<option value="largest" {{ if eq .Filters.Sort "largest" }}selected{{ end }}>Largest</option>
<option value="expires" {{ if eq .Filters.Sort "expires" }}selected{{ end }}>Expires soon</option>
<option value="expired" {{ if eq .Filters.Sort "expired" }}selected{{ end }}>Expired first</option>
</select>
</label>
<input type="hidden" name="page_size" value="{{ .PageSize }}">
<button class="win98-button" type="submit">Apply</button>
</form>
<form class="win98-window section-window" action="/account/boxes/bulk/expire" method="post">
{{ template "account_csrf_field" . }}
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">B</span>
<h2>Box Index</h2>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel boxes-table-scroll">
<table class="account-table boxes-table">
<thead>
<tr>
<th>Select</th>
<th>Box</th>
<th>Owner</th>
<th>Status</th>
<th>Files</th>
<th>Size</th>
<th>Created</th>
<th>Expires</th>
<th>Flags</th>
<th>Refresh policy</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr>
<td><input type="checkbox" name="box_ids" value="{{ .ID }}"></td>
<td>{{ .ID }}</td>
<td>{{ .Owner }}</td>
<td><span class="badge">{{ .Status }}</span></td>
<td>{{ .FileCount }}</td>
<td>{{ .Size }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ .ExpiresAt }}</td>
<td>{{ .Flags }}</td>
<td>{{ .Policy }}</td>
<td>
<div class="box-actions">
<a class="tiny-button" href="{{ .OpenURL }}">Open</a>
{{ if .CanManage }}<a class="tiny-button" href="{{ .ManageURL }}">Manage</a>{{ end }}
</div>
</td>
</tr>
{{ else }}
<tr><td colspan="11">No indexed boxes found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
<div class="bulk-actions raised-panel">
<label class="account-form-row">
<span>Bump seconds</span>
<input class="account-control" name="bump_seconds" value="3600" inputmode="numeric">
</label>
<button class="win98-button" type="submit" data-confirm="Expire selected boxes?">Expire selected</button>
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/bump-expiry">Bump selected</button>
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/delete" data-confirm="Delete selected boxes permanently?">Delete selected</button>
<button class="win98-button" type="submit" formaction="/account/boxes/delete-largest" data-confirm="Delete 10 biggest matching boxes permanently?">Delete largest 10</button>
</div>
</div>
</form>
<nav class="pagination-strip raised-panel" aria-label="Pagination">
<span class="badge">Page {{ .Page }} / {{ .TotalPages }}</span>
{{ if .HasPrev }}<a class="win98-button" href="{{ .PrevURL }}">Prev</a>{{ end }}
{{ if .HasNext }}<a class="win98-button" href="{{ .NextURL }}">Next</a>{{ end }}
</nav>
</div>
<footer class="win98-statusbar" aria-label="Boxes status">
<span>boxes index</span>
<span>{{ .Total }} matching</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,323 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="user-edit-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="User edit toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup" role="menu">
<button class="menu-action" type="button" data-ue-command="save"><span>💾</span><span>Save user</span><span class="shortcut">Ctrl+S</span></button>
<button class="menu-action" type="button" data-ue-command="discard"><span></span><span>Discard changes</span><span class="shortcut">Esc</span></button>
{{ if .CanManage }}
<div class="menu-separator"></div>
{{ if .IsPending }}
<form method="post" action="/account/users/{{ .Target.ID }}/invite/resend" style="margin:0">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span></span><span>Send invite again</span><span></span></button>
</form>
{{ end }}
<button class="menu-action" type="button" data-ue-command="reset-password"><span>🔑</span><span>Reset password</span><span></span></button>
{{ end }}
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">User</button>
<div class="menu-popup" role="menu">
{{ if .CanManage }}
{{ if not .IsSelf }}
<form method="post" action="/account/users/{{ .Target.ID }}/enable" style="margin:0">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span></span><span>Enable user</span><span></span></button>
</form>
<form method="post" action="/account/users/{{ .Target.ID }}/disable" style="margin:0">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span></span><span>Disable user</span><span></span></button>
</form>
<div class="menu-separator"></div>
{{ end }}
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke" style="margin:0">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span></span><span>Revoke all sessions</span><span></span></button>
</form>
{{ end }}
<a class="menu-action" href="/account/users"><span></span><span>Back to users</span><span></span></a>
</div>
</div>
</nav>
<div class="account-body-content">
{{ if .Error }}
<div class="account-error-banner">{{ .Error }}</div>
{{ end }}
{{ if .Success }}
<div class="account-success-banner">{{ .Success }}</div>
{{ end }}
<section class="stats-grid" aria-label="User summary">
{{ if eq .Status "active" }}
<article class="stat-card sunken-panel is-ok">
{{ else if eq .Status "pending" }}
<article class="stat-card sunken-panel is-warning">
{{ else }}
<article class="stat-card sunken-panel is-danger">
{{ end }}
<p class="stat-label">Status</p>
<p class="stat-value">{{ .Status }}</p>
<p class="stat-note">
{{ if eq .Status "active" }}<span class="stat-note-pill">can sign in</span>
{{ else if eq .Status "pending" }}<span class="stat-note-pill">invite not accepted</span>
{{ else }}<span class="stat-note-pill">blocked</span>{{ end }}
</p>
</article>
{{ if .IsAdmin }}
<article class="stat-card sunken-panel is-info">
{{ else }}
<article class="stat-card sunken-panel">
{{ end }}
<p class="stat-label">Role</p>
<p class="stat-value">{{ if .IsAdmin }}admin{{ else }}user{{ end }}</p>
<p class="stat-note">
{{ if .TagNames }}<span class="stat-note-pill">{{ .TagNames }}</span>{{ else }}<span class="stat-note-pill">no tags</span>{{ end }}
</p>
</article>
<article class="stat-card sunken-panel">
<p class="stat-label">Max file size</p>
<p class="stat-value">{{ if .MaxFileSizeStr }}{{ .MaxFileSizeStr }}{{ else }}default{{ end }}</p>
<p class="stat-note"><span class="stat-note-pill">bytes</span></p>
</article>
<article class="stat-card sunken-panel">
<p class="stat-label">Max expiry</p>
<p class="stat-value">{{ if .MaxExpiryStr }}{{ .MaxExpiryStr }}s{{ else }}default{{ end }}</p>
<p class="stat-note"><span class="stat-note-pill">seconds</span></p>
</article>
</section>
<form method="post" action="/account/users/{{ .Target.ID }}" id="user-edit-form" data-ue-form>
{{ template "account_csrf_field" . }}
<div class="ue-content-grid">
<div class="ue-column">
<section class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">A</span>
<h2>Account <span class="ue-panel-sub">identity and basic state</span></h2>
</div>
</div>
<div class="section-body sunken-panel ue-panel-body">
<div class="ue-form-grid">
<div class="ue-field">
<label for="ue-username">Username</label>
<input class="win98-input" id="ue-username" name="username" type="text" value="{{ .Target.Username }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
<span class="ue-help">Visible login name.</span>
</div>
<div class="ue-field">
<label for="ue-email">Email</label>
<input class="win98-input" id="ue-email" name="email" type="email" value="{{ .Target.Email }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
<span class="ue-help">Account contact and invite destination.</span>
</div>
{{ if not .IsPending }}
<div class="ue-field">
<label for="ue-state">State</label>
<select class="win98-select" id="ue-state" name="state" {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
<option value="active" {{ if eq .Status "active" }}selected{{ end }}>Active</option>
<option value="disabled" {{ if eq .Status "disabled" }}selected{{ end }}>Disabled</option>
</select>
<span class="ue-help">{{ if .IsSelf }}Cannot disable yourself.{{ else }}Account state.{{ end }}</span>
</div>
{{ end }}
<div class="ue-field">
<label for="ue-admin-note">Admin note</label>
<input class="win98-input" id="ue-admin-note" name="admin_note" type="text" value="{{ .Target.AdminNote }}" {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-help">Private note. Not shown to the user.</span>
</div>
</div>
</div>
</section>
<section class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">R</span>
<h2>Access rights <span class="ue-panel-sub">what this account can do</span></h2>
</div>
</div>
<div class="section-body sunken-panel ue-panel-body">
<div class="ue-check-grid">
<label class="ue-check-card">
<input type="checkbox" name="upload_allowed" value="1" {{ if index .Check "upload_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Create boxes</strong><span>Allow browser or API box creation.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="manage_own_boxes" value="1" {{ if index .Check "manage_own_boxes" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Manage own boxes</strong><span>Edit sharing, password, or expiry for owned boxes.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="renewable_allowed" value="1" {{ if index .Check "renewable_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Refresh own box expiry</strong><span>Permits time extension within limits.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="zip_download_allowed" value="1" {{ if index .Check "zip_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Use ZIP downloads</strong><span>Allow ZIP generation on this user's boxes.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="one_time_download_allowed" value="1" {{ if index .Check "one_time_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Use one-time boxes</strong><span>Permit one-time ZIP handoff boxes.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="is_admin" value="1" {{ if .IsAdmin }}checked{{ end }} {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Administrator</strong><span>Grants full admin area access. Last admin is protected.</span></span>
</label>
</div>
</div>
</section>
<section class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">L</span>
<h2>Limits <span class="ue-panel-sub">0 = unlimited, empty = system default</span></h2>
</div>
</div>
<div class="section-body sunken-panel ue-panel-body">
<div class="ue-form-grid">
<div class="ue-field">
<label for="ue-max-file">Max file size (bytes)</label>
<input class="win98-input" id="ue-max-file" name="max_file_size_bytes" type="number" min="0"
value="{{ .MaxFileSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-help">Per-file cap. Empty = system default.</span>
</div>
<div class="ue-field">
<label for="ue-max-box">Max box size (bytes)</label>
<input class="win98-input" id="ue-max-box" name="max_box_size_bytes" type="number" min="0"
value="{{ .MaxBoxSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-help">Total size per box. Empty = system default.</span>
</div>
<div class="ue-field ue-field-full">
<label for="ue-max-expiry">Max box expiry (seconds)</label>
<input class="win98-input" id="ue-max-expiry" name="max_expiry_seconds" type="number" min="0"
value="{{ .MaxExpiryStr }}" {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-help">Maximum expiry when creating or editing a box. Empty = system default.</span>
</div>
</div>
</div>
</section>
</div>
<div class="ue-column">
<section class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">O</span>
<h2>Setting overrides <span class="ue-panel-sub">account-specific behavior</span></h2>
</div>
</div>
<div class="section-body sunken-panel ue-panel-body">
<div class="ue-check-grid ue-check-grid-1col">
<label class="ue-check-card">
<input type="checkbox" name="allow_password_protected" value="1" {{ if index .Check "allow_password_protected" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Allow password-protected boxes</strong><span>Overrides system default for this account.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="renew_on_access" value="1" {{ if index .Check "renew_on_access" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Allow renew on access</strong><span>Only applies when the global feature is enabled.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="renew_on_download" value="1" {{ if index .Check "renew_on_download" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Allow renew on download</strong><span>Only applies when the global feature is enabled.</span></span>
</label>
<label class="ue-check-card">
<input type="checkbox" name="allow_owner_box_editing" value="1" {{ if index .Check "allow_owner_box_editing" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
<span class="ue-check-copy"><strong>Allow owner box editing</strong><span>Lets the user open the box edit page for owned boxes.</span></span>
</label>
</div>
</div>
</section>
<section class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">P</span>
<h2>Resolved policy <span class="ue-panel-sub">effective permissions after all overrides</span></h2>
</div>
</div>
<div class="section-body sunken-panel ue-panel-body">
<pre class="ue-policy-pre">{{ .PolicyJSON }}</pre>
</div>
</section>
<section class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">I</span>
<h2>Account info <span class="ue-panel-sub">read-only details</span></h2>
</div>
</div>
<div class="section-body sunken-panel ue-panel-body">
<ul class="ue-info-list">
<li class="ue-info-item"><strong>User ID</strong><span>{{ .Target.ID }}</span></li>
<li class="ue-info-item"><strong>Created</strong><span>{{ .CreatedAtStr }}</span></li>
<li class="ue-info-item"><strong>Updated</strong><span>{{ .UpdatedAtStr }}</span></li>
<li class="ue-info-item"><strong>Tags</strong><span>{{ if .TagNames }}{{ .TagNames }}{{ else }}none{{ end }}</span></li>
<li class="ue-info-item"><strong>Password</strong><span>{{ if .IsPending }}pending invite{{ else }}set{{ end }}</span></li>
</ul>
</div>
</section>
{{ if .CanManage }}
<section class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">!</span>
<h2>Danger zone</h2>
</div>
</div>
<div class="section-body sunken-panel ue-panel-body">
<div class="ue-danger-row">
<form method="post" action="/account/users/{{ .Target.ID }}/password/reset">
{{ template "account_csrf_field" . }}
<button class="win98-button ue-danger-btn" type="submit">Reset password</button>
</form>
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke">
{{ template "account_csrf_field" . }}
<button class="win98-button" type="submit">Revoke sessions</button>
</form>
{{ if not .IsSelf }}
<form method="post" action="/account/users/{{ .Target.ID }}/{{ if .Target.Disabled }}enable{{ else }}disable{{ end }}">
{{ template "account_csrf_field" . }}
<button class="win98-button ue-danger-btn" type="submit">{{ if .Target.Disabled }}Enable{{ else }}Disable{{ end }} user</button>
</form>
{{ end }}
</div>
</div>
</section>
{{ end }}
</div>
</div>
<div class="ue-footer">
<div class="ue-footer-left">
<span class="stat-note-pill" data-ue-dirty>No unsaved changes</span>
<a class="stat-note-pill" href="/account/users">← Back to users</a>
</div>
<div class="ue-footer-right">
{{ if .CanManage }}
<button class="win98-button" type="button" data-ue-command="discard">Discard</button>
<button class="win98-button" type="submit">Save user</button>
{{ end }}
</div>
</div>
</form>
</div>
<footer class="win98-statusbar" aria-label="User edit status">
<span>editing: {{ .Target.Username }}</span>
<span>signed in: {{ .AccountNav.Username }}</span>
<span>{{ .Status }}</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

@@ -0,0 +1,257 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-users-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Users 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/users"><span>R</span><span>Refresh list</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="/account/users?status=active"><span>A</span><span>Show active</span><span></span></a>
<a class="menu-action" href="/account/users?status=disabled"><span>D</span><span>Show disabled</span><span></span></a>
<a class="menu-action" href="/account/users"><span>X</span><span>Clear filters</span><span></span></a>
</div>
</div>
</nav>
<div class="account-body-content">
<section class="dashboard-hero raised-panel" aria-label="Users overview">
<div class="hero-copy">
<h2 id="account-users-title">WarpBox Users</h2>
<p>Accounts, invites, and access. Search, filter, and manage users with safe bulk actions.</p>
</div>
<div class="hero-actions">
<button class="small-action is-primary" type="button" data-users-action="focus-create">Create / Invite</button>
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
<button class="small-action" type="button" onclick="location.href='/account/users'">Refresh</button>
</div>
</section>
{{ if .Error }}
<div class="account-error-banner">{{ .Error }}</div>
{{ end }}
{{ if .Success }}
<div class="account-success-banner">{{ .Success }}</div>
{{ end }}
<section class="stats-grid" aria-label="User statistics">
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Total users</p>
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
<p class="stat-note"><span class="stat-note-pill">all</span></p>
</article>
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Active</p>
<p class="stat-value">{{ .Stats.ActiveUsers }}</p>
<p class="stat-note"><span class="stat-note-pill">enabled</span></p>
</article>
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Pending invites</p>
<p class="stat-value">{{ .Stats.PendingInvites }}</p>
<p class="stat-note"><span class="stat-note-pill">awaiting setup</span></p>
</article>
<article class="stat-card sunken-panel is-danger">
<p class="stat-label">Disabled</p>
<p class="stat-value">{{ .Stats.DisabledUsers }}</p>
<p class="stat-note"><span class="stat-note-pill">blocked</span></p>
</article>
</section>
<section class="main-grid users-grid" aria-label="Users panel and form">
<aside class="win98-window section-window users-form-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">+</span>
<h2>Create or Invite</h2>
</div>
</div>
<div class="section-body sunken-panel">
<form class="form-grid" method="post" action="/account/users">
{{ template "account_csrf_field" . }}
<input type="hidden" name="action" value="create">
<div class="field-row">
<label for="users-mode">Mode</label>
<select class="win98-select" name="mode" id="users-mode">
<option value="create">Create local user</option>
<option value="invite">Send invite</option>
</select>
<div class="field-help">Invite creates a disabled account with a setup link. Create makes an active user immediately.</div>
</div>
<div class="field-row">
<label for="users-username">Username</label>
<input class="win98-input" name="username" id="users-username" required placeholder="username" autocomplete="off">
</div>
<div class="field-row">
<label for="users-email">Email</label>
<input class="win98-input" name="email" id="users-email" type="email" required placeholder="user@example.test" autocomplete="off">
</div>
<div class="field-row">
<label for="users-password">Password</label>
<input class="win98-input" name="password" id="users-password" type="password" autocomplete="new-password" placeholder="Leave empty for auto-generated">
<div class="field-help">If empty, a temporary password will be generated. Never prefill passwords.</div>
</div>
<div class="field-row">
<label for="users-role">Role</label>
<select class="win98-select" name="role" id="users-role">
<option value="all">No tag (default)</option>
{{ range .Tags }}
<option value="{{ .Name }}">{{ .Name }}</option>
{{ end }}
</select>
<div class="field-help">Assign an initial role tag. Permissions are resolved from tag settings.</div>
</div>
<div class="button-row">
<button class="small-action" type="reset">Clear</button>
<button class="small-action is-primary" type="submit">Apply</button>
</div>
</form>
</div>
</aside>
<section class="win98-window section-window span-2 users-table-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">U</span>
<h2>Users</h2>
</div>
</div>
<div class="users-filters-bar">
<form class="users-filters-form" method="get" action="/account/users" id="users-filters-form">
<input class="win98-input" name="q" value="{{ .Filters.Query }}" placeholder="Search username or email">
<select class="win98-select" name="status" onchange="this.form.submit()">
<option value="" {{ if eq .Filters.Status "" }}selected{{ end }}>all statuses</option>
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>active</option>
<option value="disabled" {{ if eq .Filters.Status "disabled" }}selected{{ end }}>disabled</option>
</select>
<select class="win98-select" name="role" onchange="this.form.submit()">
<option value="" {{ if eq .Filters.Role "" }}selected{{ end }}>all roles</option>
{{ range .Tags }}
<option value="{{ .Name }}" {{ if eq $.Filters.Role .Name }}selected{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
<select class="win98-select" name="sort" onchange="this.form.submit()">
<option value="username" {{ if eq .Filters.Sort "username" }}selected{{ end }}>sort username</option>
<option value="createdDesc" {{ if eq .Filters.Sort "createdDesc" }}selected{{ end }}>newest first</option>
</select>
<select class="win98-select" name="page_size" onchange="this.form.submit()">
<option value="12" {{ if eq .Filters.PageSize 12 }}selected{{ end }}>12 rows</option>
<option value="20" {{ if eq .Filters.PageSize 20 }}selected{{ end }}>20 rows</option>
<option value="50" {{ if eq .Filters.PageSize 50 }}selected{{ end }}>50 rows</option>
</select>
<noscript><button class="small-action" type="submit">Filter</button></noscript>
</form>
</div>
<form id="users-bulk-form" method="post" action="/account/users/bulk/disable">
{{ template "account_csrf_field" . }}
<input type="hidden" name="selected_ids" value="" id="bulk-selected-ids">
<div class="users-bulk-strip">
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
<button class="small-action" type="submit" data-users-action="bulk-disable" onclick="setBulkAction('/account/users/bulk/disable')">Disable</button>
<button class="small-action" type="submit" data-users-action="bulk-enable" onclick="setBulkAction('/account/users/bulk/enable')">Enable</button>
<button class="small-action" type="submit" data-users-action="bulk-revoke" onclick="setBulkAction('/account/users/bulk/revoke-sessions')">Revoke sessions</button>
<span class="stat-note-pill" id="selected-count">0 selected</span>
</div>
<div class="section-body sunken-panel table-body-panel">
<div class="table-scroll">
<table class="account-table" aria-label="Users">
<thead>
<tr>
<th class="check-cell"><input type="checkbox" id="master-check" aria-label="Select current page"></th>
<th>User</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Plan</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr data-user-id="{{ .ID }}">
<td class="check-cell">
<input type="checkbox" class="row-check" value="{{ .ID }}" data-user-id="{{ .ID }}" aria-label="Select {{ .Username }}">
</td>
<td class="user-cell">
<div class="user-main">
<span class="username">{{ .Username }}{{ if .IsCurrent }} <span class="pill is-info">you</span>{{ end }}</span>
<span class="subtle">id: {{ .ID }}</span>
</div>
</td>
<td class="email-cell" title="{{ .Email }}">{{ .Email }}</td>
<td>
{{ if eq .Status "active" }}
<span class="pill is-ok">active</span>
{{ else }}
<span class="pill is-danger">disabled</span>
{{ end }}
</td>
<td><span class="pill is-info">{{ .Role }}</span></td>
<td><span class="pill">{{ .Plan }}</span></td>
<td>{{ .CreatedAt }}</td>
<td class="actions-cell">
<a class="tiny-button" href="/account/users/{{ .ID }}">Edit</a>
{{ if and .IsInvite (not .IsCurrent) }}
<form method="post" action="/account/users/{{ .ID }}/invite/resend" style="display:inline">
{{ template "account_csrf_field" $ }}
<button class="tiny-button" type="submit">Resend invite</button>
</form>
{{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="8">No users found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</form>
<div class="pagination">
<span class="pagination-info">
Page {{ .Page }} of {{ .TotalPages }} &mdash; {{ .Total }} matching user(s)
</span>
<div class="pagination-controls">
{{ if .HasPrev }}
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .PrevPage }}">Prev</a>
{{ else }}
<button class="small-action" disabled>Prev</button>
{{ end }}
{{ if .HasNext }}
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .NextPage }}">Next</a>
{{ else }}
<button class="small-action" disabled>Next</button>
{{ end }}
</div>
</div>
</section>
</section>
</div>
<footer class="win98-statusbar" aria-label="Users 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" . }}

40
templates/admin.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-title">WarpBox Admin</h1>
</div>
</header>
<div class="win98-panel admin-panel">
<nav class="admin-nav">
<span>Signed in as {{ .CurrentUser }}</span>
<span class="admin-spacer"></span>
<form action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<button class="win98-button" type="submit">Logout</button>
</form>
</nav>
<div class="admin-grid">
<a class="win98-panel admin-link" href="/admin/boxes"><strong>Boxes</strong></a>
<a class="win98-panel admin-link" href="/admin/users"><strong>Users</strong></a>
<a class="win98-panel admin-link" href="/admin/tags"><strong>Tags</strong></a>
<a class="win98-panel admin-link" href="/admin/settings"><strong>Settings</strong></a>
</div>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Boxes</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-boxes-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-boxes-title">Boxes</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
<div class="admin-summary">
<span class="win98-panel">Boxes: {{ .TotalBoxes }}</span>
<span class="win98-panel">Storage: {{ .TotalStorage }}</span>
<span class="win98-panel">Expired: {{ .ExpiredBoxes }}</span>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Box ID</th>
<th>Files</th>
<th>Size</th>
<th>Created</th>
<th>Expires</th>
<th>Flags</th>
</tr>
</thead>
<tbody>
{{ range .Boxes }}
<tr>
<td><a href="/box/{{ .ID }}">{{ .ID }}</a></td>
<td>{{ .FileCount }}</td>
<td>{{ .TotalSizeLabel }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ .ExpiresAt }}</td>
<td>
{{ if .Expired }}expired {{ end }}
{{ if .OneTimeDownload }}one-time {{ end }}
{{ if .PasswordProtected }}password {{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="6">No boxes found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Login</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-login-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-login-title">WarpBox Admin</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
{{ if .AdminLoginEnabled }}
<form class="admin-form" action="/admin/login" method="post">
<label class="admin-form-row">
<span>Username</span>
<input name="username" autocomplete="username" required>
</label>
<label class="admin-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>Administrator login is disabled. Set WARPBOX_ADMIN_PASSWORD and restart to bootstrap the first admin user.</p>
{{ end }}
</div>
</section>
</main>
</body>
</html>

11
templates/admin_nav.html Normal file
View File

@@ -0,0 +1,11 @@
{{ define "admin_nav" }}
<nav class="admin-nav">
{{ if ne .AdminSection "dashboard" }}<a class="win98-button" href="/admin">Admin</a>{{ end }}
{{ if ne .AdminSection "boxes" }}<a class="win98-button" href="/admin/boxes">Boxes</a>{{ end }}
{{ if ne .AdminSection "users" }}<a class="win98-button" href="/admin/users">Users</a>{{ end }}
{{ if ne .AdminSection "tags" }}<a class="win98-button" href="/admin/tags">Tags</a>{{ end }}
{{ if ne .AdminSection "settings" }}<a class="win98-button" href="/admin/settings">Settings</a>{{ end }}
<span class="admin-spacer"></span>
<span>{{ .CurrentUser }}</span>
</nav>
{{ end }}

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Settings</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-settings-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-settings-title">Settings</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
<form class="admin-form" action="/admin/settings" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<table class="admin-table">
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
<th>Source</th>
<th>Env</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr>
<td>{{ .Definition.Label }}{{ if .Definition.HardLimit }} (hard){{ end }}</td>
<td>
{{ if and $.OverridesAllowed .Definition.Editable }}
{{ if eq .Definition.Type "bool" }}
<input type="checkbox" name="{{ .Definition.Key }}" value="true" {{ if eq .Value "true" }}checked{{ end }}>
{{ else }}
<input name="{{ .Definition.Key }}" value="{{ .Value }}" inputmode="numeric">
{{ end }}
{{ else }}
{{ .Value }}
{{ end }}
</td>
<td>{{ .Source }}</td>
<td>{{ .Definition.EnvName }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ if .OverridesAllowed }}
<button class="win98-button" type="submit">Save Settings</button>
{{ end }}
</form>
</div>
</section>
</main>
</body>
</html>

96
templates/admin_tags.html Normal file
View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Tags</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-tags-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-tags-title">Tags</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
<form class="admin-form win98-panel" action="/admin/tags" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<label class="admin-form-row">
<span>Name</span>
<input name="name" required>
</label>
<label class="admin-form-row">
<span>Description</span>
<textarea name="description" rows="2"></textarea>
</label>
<div class="admin-checks">
<label><input type="checkbox" name="admin_access" value="true"><span>Admin access</span></label>
<label><input type="checkbox" name="admin_users_manage" value="true"><span>Manage users</span></label>
<label><input type="checkbox" name="admin_settings_manage" value="true"><span>Manage settings</span></label>
<label><input type="checkbox" name="admin_boxes_view" value="true"><span>View boxes</span></label>
<label><input type="checkbox" name="upload_allowed" value="true"><span>Upload allowed</span></label>
<label><input type="checkbox" name="zip_download_allowed" value="true"><span>ZIP allowed</span></label>
<label><input type="checkbox" name="one_time_download_allowed" value="true"><span>One-time allowed</span></label>
<label><input type="checkbox" name="renewable_allowed" value="true"><span>Renewable allowed</span></label>
</div>
<label class="admin-form-row">
<span>Max file size bytes</span>
<input name="max_file_size_bytes" inputmode="numeric">
</label>
<label class="admin-form-row">
<span>Max box size bytes</span>
<input name="max_box_size_bytes" inputmode="numeric">
</label>
<label class="admin-form-row">
<span>Allowed expiry seconds</span>
<input name="allowed_expiry_seconds" placeholder="600, 3600, 86400">
</label>
<button class="win98-button" type="submit">Create Tag</button>
</form>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Flags</th>
<th>Max file</th>
<th>Max box</th>
<th>Expiry seconds</th>
</tr>
</thead>
<tbody>
{{ range .Tags }}
<tr>
<td>{{ .Name }} {{ if .Protected }}(protected){{ end }}</td>
<td>{{ .Description }}</td>
<td>
{{ if .AdminAccess }}admin {{ end }}
{{ if .UploadAllowed }}upload {{ end }}
{{ if .ZipDownloadAllowed }}zip {{ end }}
{{ if .OneTimeDownloadAllowed }}one-time {{ end }}
{{ if .RenewableAllowed }}renew {{ end }}
</td>
<td>{{ .MaxFileSizeBytes }}</td>
<td>{{ .MaxBoxSizeBytes }}</td>
<td>{{ .AllowedExpirySeconds }}</td>
</tr>
{{ else }}
<tr><td colspan="6">No tags found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Users</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-users-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-users-title">Users</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
<form class="admin-form win98-panel" action="/admin/users" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<label class="admin-form-row">
<span>Username</span>
<input name="username" required>
</label>
<label class="admin-form-row">
<span>Email</span>
<input name="email" type="email">
</label>
<label class="admin-form-row">
<span>Password</span>
<input name="password" type="password" autocomplete="new-password" required>
</label>
<div class="admin-checks">
{{ range .Tags }}
<label>
<input type="checkbox" name="tag_ids" value="{{ .ID }}">
<span>{{ .Name }}</span>
</label>
{{ end }}
</div>
<button class="win98-button" type="submit">Create User</button>
</form>
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Tags</th>
<th>Created</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{{ range .Users }}
<tr>
<td>{{ .Username }}</td>
<td>{{ .Email }}</td>
<td>{{ .Tags }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ if .Disabled }}Disabled{{ else }}Active{{ end }}</td>
<td>
<form action="/admin/users" method="post">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="action" value="toggle_disabled">
<input type="hidden" name="user_id" value="{{ .ID }}">
<button class="win98-button" type="submit" {{ if .IsCurrent }}disabled{{ end }}>{{ if .Disabled }}Enable{{ else }}Disable{{ end }}</button>
</form>
</td>
</tr>
{{ else }}
<tr><td colspan="6">No users found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>