diff --git a/cmd/cmd_env.go b/cmd/cmd_env.go index 345149d..ac06789 100644 --- a/cmd/cmd_env.go +++ b/cmd/cmd_env.go @@ -151,11 +151,6 @@ func buildAllEnvRows(includeHidden bool) []envRow { } extra := buildExtraEnvRows(includeHidden) - if loadErr == nil { - for i := range extra { - extra[i].Default = extra[i].Default - } - } rows = append(rows, extra...) return rows diff --git a/go.mod b/go.mod index 4b6f1aa..3e3cd58 100644 --- a/go.mod +++ b/go.mod @@ -3,51 +3,43 @@ 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/flatbuffers v25.2.10+incompatible // indirect + github.com/google/go-cmp v0.7.0 // 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/spf13/pflag v1.0.6 // indirect + github.com/rogpeppe/go-internal v1.13.1 // 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 ) diff --git a/go.sum b/go.sum index 1c11e0f..a4f01fb 100644 --- a/go.sum +++ b/go.sum @@ -2,24 +2,15 @@ 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= @@ -28,11 +19,6 @@ 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= @@ -43,8 +29,6 @@ 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= @@ -52,14 +36,15 @@ 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= @@ -73,8 +58,10 @@ 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= @@ -99,14 +86,6 @@ 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= diff --git a/lib/metastore/bootstrap.go b/lib/metastore/bootstrap.go deleted file mode 100644 index a8a807f..0000000 --- a/lib/metastore/bootstrap.go +++ /dev/null @@ -1,71 +0,0 @@ -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 -} diff --git a/lib/metastore/metastore_test.go b/lib/metastore/metastore_test.go deleted file mode 100644 index cb2a1ec..0000000 --- a/lib/metastore/metastore_test.go +++ /dev/null @@ -1,222 +0,0 @@ -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, "") - } -} diff --git a/lib/metastore/models.go b/lib/metastore/models.go deleted file mode 100644 index 1b39098..0000000 --- a/lib/metastore/models.go +++ /dev/null @@ -1,76 +0,0 @@ -package metastore - -import "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"` - MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"` - MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"` - MaxExpirySeconds *int64 `json:"max_expiry_seconds,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"` - 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 - AdminUsersManage bool - AdminSettingsManage bool - AdminBoxesView bool -} - -type BootstrapResult struct { - AdminTag Tag - AdminUser *User - AdminLoginEnabled bool -} diff --git a/lib/metastore/permissions.go b/lib/metastore/permissions.go deleted file mode 100644 index 0a7954b..0000000 --- a/lib/metastore/permissions.go +++ /dev/null @@ -1,141 +0,0 @@ -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.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 - } - - 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 -} diff --git a/lib/metastore/sessions.go b/lib/metastore/sessions.go deleted file mode 100644 index b4684d5..0000000 --- a/lib/metastore/sessions.go +++ /dev/null @@ -1,79 +0,0 @@ -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)) -} diff --git a/lib/metastore/store.go b/lib/metastore/store.go deleted file mode 100644 index 13fecad..0000000 --- a/lib/metastore/store.go +++ /dev/null @@ -1,379 +0,0 @@ -package metastore - -import ( - "encoding/json" - "errors" - "fmt" - "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) 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 -} diff --git a/lib/metastore/tags.go b/lib/metastore/tags.go deleted file mode 100644 index 4252140..0000000 --- a/lib/metastore/tags.go +++ /dev/null @@ -1,220 +0,0 @@ -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, - 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)) -} diff --git a/lib/server/admin_auth.go b/lib/server/admin_auth.go deleted file mode 100644 index c787d79..0000000 --- a/lib/server/admin_auth.go +++ /dev/null @@ -1,192 +0,0 @@ -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") - 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 -} diff --git a/lib/server/admin_boxes.go b/lib/server/admin_boxes.go deleted file mode 100644 index e73eecd..0000000 --- a/lib/server/admin_boxes.go +++ /dev/null @@ -1,63 +0,0 @@ -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, - }) -} diff --git a/lib/server/admin_dashboard.go b/lib/server/admin_dashboard.go deleted file mode 100644 index da37874..0000000 --- a/lib/server/admin_dashboard.go +++ /dev/null @@ -1,14 +0,0 @@ -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), - }) -} diff --git a/lib/server/admin_format.go b/lib/server/admin_format.go deleted file mode 100644 index a3314cd..0000000 --- a/lib/server/admin_format.go +++ /dev/null @@ -1,73 +0,0 @@ -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") -} diff --git a/lib/server/admin_routes.go b/lib/server/admin_routes.go deleted file mode 100644 index 29e31a4..0000000 --- a/lib/server/admin_routes.go +++ /dev/null @@ -1,23 +0,0 @@ -package server - -import "github.com/gin-gonic/gin" - -func (app *App) registerAdminRoutes(router *gin.Engine) { - admin := router.Group("/admin") - admin.Use(noStoreAdminHeaders) - admin.GET("/login", app.handleAdminLogin) - admin.POST("/login", app.handleAdminLoginPost) - - protected := admin.Group("") - protected.Use(app.requireAdminSession) - protected.POST("/logout", app.handleAdminLogout) - protected.GET("", app.handleAdminDashboard) - protected.GET("/", app.handleAdminDashboard) - protected.GET("/boxes", app.handleAdminBoxes) - protected.GET("/users", app.handleAdminUsers) - protected.POST("/users", app.handleAdminUsersPost) - protected.GET("/tags", app.handleAdminTags) - protected.POST("/tags", app.handleAdminTagsPost) - protected.GET("/settings", app.handleAdminSettings) - protected.POST("/settings", app.handleAdminSettingsPost) -} diff --git a/lib/server/admin_settings.go b/lib/server/admin_settings.go deleted file mode 100644 index cb82912..0000000 --- a/lib/server/admin_settings.go +++ /dev/null @@ -1,58 +0,0 @@ -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, - }) -} diff --git a/lib/server/admin_tags.go b/lib/server/admin_tags.go deleted file mode 100644 index 2367691..0000000 --- a/lib/server/admin_tags.go +++ /dev/null @@ -1,122 +0,0 @@ -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" -} diff --git a/lib/server/admin_users.go b/lib/server/admin_users.go deleted file mode 100644 index 1a9f4f1..0000000 --- a/lib/server/admin_users.go +++ /dev/null @@ -1,121 +0,0 @@ -package server - -import ( - "net/http" - "sort" - "strings" - - "github.com/gin-gonic/gin" - - "warpbox/lib/metastore" -) - -type adminUserRow struct { - ID string - Username string - Email string - Tags string - CreatedAt string - Disabled bool - IsCurrent bool -} - -func (app *App) handleAdminUsers(ctx *gin.Context) { - if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) { - return - } - app.renderAdminUsers(ctx, "") -} - -func (app *App) handleAdminUsersPost(ctx *gin.Context) { - if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) { - return - } - - if ctx.PostForm("action") == "toggle_disabled" { - userID := strings.TrimSpace(ctx.PostForm("user_id")) - user, ok, err := app.store.GetUser(userID) - if err != nil || !ok { - app.renderAdminUsers(ctx, "User not found.") - return - } - if current, ok := ctx.Get("adminUser"); ok { - if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID { - app.renderAdminUsers(ctx, "You cannot disable the user for the active session.") - return - } - } - user.Disabled = !user.Disabled - if err := app.store.UpdateUser(user); err != nil { - app.renderAdminUsers(ctx, err.Error()) - return - } - ctx.Redirect(http.StatusSeeOther, "/admin/users") - return - } - - username := ctx.PostForm("username") - email := ctx.PostForm("email") - password := ctx.PostForm("password") - tagIDs := ctx.PostFormArray("tag_ids") - if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil { - app.renderAdminUsers(ctx, err.Error()) - return - } - ctx.Redirect(http.StatusSeeOther, "/admin/users") -} - -func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) { - users, err := app.store.ListUsers() - if err != nil { - ctx.String(http.StatusInternalServerError, "Could not list users") - return - } - tags, err := app.store.ListTags() - if err != nil { - ctx.String(http.StatusInternalServerError, "Could not list tags") - return - } - tagNames := make(map[string]string, len(tags)) - for _, tag := range tags { - tagNames[tag.ID] = tag.Name - } - sort.Slice(users, func(i int, j int) bool { - return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username) - }) - - currentID := "" - if current, ok := ctx.Get("adminUser"); ok { - if currentUser, ok := current.(metastore.User); ok { - currentID = currentUser.ID - } - } - - rows := make([]adminUserRow, 0, len(users)) - for _, user := range users { - names := make([]string, 0, len(user.TagIDs)) - for _, tagID := range user.TagIDs { - if name := tagNames[tagID]; name != "" { - names = append(names, name) - } - } - rows = append(rows, adminUserRow{ - ID: user.ID, - Username: user.Username, - Email: user.Email, - Tags: strings.Join(names, ", "), - CreatedAt: formatAdminTime(user.CreatedAt), - Disabled: user.Disabled, - IsCurrent: user.ID == currentID, - }) - } - - ctx.HTML(http.StatusOK, "admin_users.html", gin.H{ - "AdminSection": "users", - "CurrentUser": app.currentAdminUsername(ctx), - "CSRFToken": app.currentCSRFToken(ctx), - "Users": rows, - "Tags": tags, - "Error": errorMessage, - }) -} diff --git a/lib/server/security_test.go b/lib/server/security_test.go index 3a0d985..063faec 100644 --- a/lib/server/security_test.go +++ b/lib/server/security_test.go @@ -1,17 +1,12 @@ 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" ) @@ -40,40 +35,3 @@ 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) - } -} diff --git a/lib/server/server.go b/lib/server/server.go index 26c3876..867046c 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -1,7 +1,6 @@ package server import ( - "fmt" "time" "github.com/gin-contrib/gzip" @@ -9,14 +8,11 @@ import ( "warpbox/lib/boxstore" "warpbox/lib/config" - "warpbox/lib/metastore" "warpbox/lib/routing" ) type App struct { - config *config.Config - store *metastore.Store - adminLoginEnabled bool + config *config.Config } func Run(addr string) error { @@ -30,31 +26,7 @@ func Run(addr string) error { applyBoxstoreRuntimeConfig(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, - } + app := &App{config: cfg} router := gin.Default() router.LoadHTMLGlob("templates/*.html") @@ -74,7 +46,6 @@ func Run(addr string) error { DirectBoxUpload: app.handleDirectBoxUpload, LegacyUpload: app.handleLegacyUpload, }) - app.registerAdminRoutes(router) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) compressed.Static("/static", "./static") diff --git a/static/css/admin.css b/static/css/admin.css deleted file mode 100644 index 654f157..0000000 --- a/static/css/admin.css +++ /dev/null @@ -1,132 +0,0 @@ -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; -} diff --git a/templates/admin.html b/templates/admin.html deleted file mode 100644 index bf7044a..0000000 --- a/templates/admin.html +++ /dev/null @@ -1,40 +0,0 @@ - - -
- - -| Box ID | -Files | -Size | -Created | -Expires | -Flags | -
|---|---|---|---|---|---|
| {{ .ID }} | -{{ .FileCount }} | -{{ .TotalSizeLabel }} | -{{ .CreatedAt }} | -{{ .ExpiresAt }} | -- {{ if .Expired }}expired {{ end }} - {{ if .OneTimeDownload }}one-time {{ end }} - {{ if .PasswordProtected }}password {{ end }} - | -
| No boxes found. | |||||
{{ .Error }}
- {{ end }} - {{ if .AdminLoginEnabled }} - - {{ else }} -Administrator login is disabled. Set WARPBOX_ADMIN_PASSWORD and restart to bootstrap the first admin user.
- {{ end }} -{{ .Error }}
- {{ end }} - -{{ .Error }}
- {{ end }} - -| Name | -Description | -Flags | -Max file | -Max box | -Expiry seconds | -
|---|---|---|---|---|---|
| {{ .Name }} {{ if .Protected }}(protected){{ end }} | -{{ .Description }} | -- {{ if .AdminAccess }}admin {{ end }} - {{ if .UploadAllowed }}upload {{ end }} - {{ if .ZipDownloadAllowed }}zip {{ end }} - {{ if .OneTimeDownloadAllowed }}one-time {{ end }} - {{ if .RenewableAllowed }}renew {{ end }} - | -{{ .MaxFileSizeBytes }} | -{{ .MaxBoxSizeBytes }} | -{{ .AllowedExpirySeconds }} | -
| No tags found. | |||||
{{ .Error }}
- {{ end }} - -| Username | -Tags | -Created | -Status | -Action | -|
|---|---|---|---|---|---|
| {{ .Username }} | -{{ .Email }} | -{{ .Tags }} | -{{ .CreatedAt }} | -{{ if .Disabled }}Disabled{{ else }}Active{{ end }} | -- - | -
| No users found. | |||||