cleanup(admin): This large-scale refactoring effort involves cleaning up redundant logic and standardizing database interactions across several modules.
This commit is contained in:
@@ -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
|
||||
|
||||
18
go.mod
18
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
|
||||
)
|
||||
|
||||
33
go.sum
33
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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,62 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,44 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,11 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,66 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,96 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,87 +0,0 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user