feat(storage): add S3 backend support and advanced upload limits

- Introduce S3-compatible storage backend support using minio-go.
- Add configuration options for local storage limits, box limits, and rate limiting.
- Implement storage backend selection (local vs S3) for anonymous and registered users.
- Add an `/admin/storage` management interface.
- Update documentation and environment examples with the new configuration variables.
This commit is contained in:
2026-05-31 02:14:10 +03:00
parent 830d2a885c
commit c3558fd353
34 changed files with 2668 additions and 168 deletions

View File

@@ -16,6 +16,17 @@ WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
WARPBOX_USER_DAILY_UPLOAD_MB=8192 WARPBOX_USER_DAILY_UPLOAD_MB=8192
WARPBOX_DEFAULT_USER_STORAGE_MB=51200 WARPBOX_DEFAULT_USER_STORAGE_MB=51200
WARPBOX_USAGE_RETENTION_DAYS=30 WARPBOX_USAGE_RETENTION_DAYS=30
WARPBOX_LOCAL_STORAGE_MAX_GB=100
WARPBOX_ANONYMOUS_MAX_DAYS=30
WARPBOX_USER_MAX_DAYS=90
WARPBOX_ANONYMOUS_DAILY_BOXES=100
WARPBOX_USER_DAILY_BOXES=250
WARPBOX_ANONYMOUS_ACTIVE_BOXES=500
WARPBOX_USER_ACTIVE_BOXES=1000
WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_IDLE_TIMEOUT=120s WARPBOX_IDLE_TIMEOUT=120s

View File

@@ -22,6 +22,17 @@ Upload policy defaults are also configured in megabytes and can later be changed
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192` - `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200` - `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
- `WARPBOX_USAGE_RETENTION_DAYS=30` - `WARPBOX_USAGE_RETENTION_DAYS=30`
- `WARPBOX_LOCAL_STORAGE_MAX_GB=100`
- `WARPBOX_ANONYMOUS_MAX_DAYS=30`
- `WARPBOX_USER_MAX_DAYS=90`
- `WARPBOX_ANONYMOUS_DAILY_BOXES=100`
- `WARPBOX_USER_DAILY_BOXES=250`
- `WARPBOX_ANONYMOUS_ACTIVE_BOXES=500`
- `WARPBOX_USER_ACTIVE_BOXES=1000`
- `WARPBOX_SHORT_WINDOW_REQUESTS=60`
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
- `WARPBOX_USER_STORAGE_BACKEND=local`
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
The dev script resolves that path from the repository root. The dev script resolves that path from the repository root.
@@ -126,6 +137,11 @@ from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default - `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
user storage quota, and usage retention. user storage quota, and usage retention.
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides. - `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request
limits, max expiration days, local storage capacity in GB, and per-user policy overrides.
- Uploaded file content, thumbnails, and private box metadata use the selected storage backend.
The bbolt database and JSON logs remain local under `./data/db` and `./data/logs`.
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete - Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
tokens, thumbnails, and cleanup continue to work as before. tokens, thumbnails, and cleanup continue to work as before.
@@ -136,8 +152,8 @@ support will power public forgot-password and optional email delivery.
Warpbox keeps local runtime data under the configured data directory: Warpbox keeps local runtime data under the configured data directory:
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents. - `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents when the local backend is selected.
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available. - `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews when the local backend is selected.
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records. - `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections. - `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP - `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP

View File

@@ -3,9 +3,28 @@ module warpbox.dev/backend
go 1.26 go 1.26
require ( require (
github.com/minio/minio-go/v7 v7.2.0
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.33.0 golang.org/x/crypto v0.51.0
golang.org/x/image v0.41.0 golang.org/x/image v0.41.0
) )
require golang.org/x/sys v0.30.0 // indirect require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
)

View File

@@ -1,18 +1,66 @@
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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -38,6 +38,17 @@ type SettingsDefaults struct {
UserDailyUploadMB float64 UserDailyUploadMB float64
DefaultUserStorageMB float64 DefaultUserStorageMB float64
UsageRetentionDays int UsageRetentionDays int
LocalStorageMaxGB float64
AnonymousMaxDays int
UserMaxDays int
AnonymousDailyBoxes int
UserDailyBoxes int
AnonymousActiveBoxes int
UserActiveBoxes int
ShortWindowRequests int
ShortWindowSeconds int
AnonymousStorageBackend string
UserStorageBackend string
} }
func Load() (Config, error) { func Load() (Config, error) {
@@ -66,6 +77,17 @@ func Load() (Config, error) {
UserDailyUploadMB: envMegabytesFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192), UserDailyUploadMB: envMegabytesFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192),
DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200), DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200),
UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30), UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30),
LocalStorageMaxGB: envGigabytesFloat("WARPBOX_LOCAL_STORAGE_MAX_GB", 100),
AnonymousMaxDays: envInt("WARPBOX_ANONYMOUS_MAX_DAYS", 30),
UserMaxDays: envInt("WARPBOX_USER_MAX_DAYS", 90),
AnonymousDailyBoxes: envInt("WARPBOX_ANONYMOUS_DAILY_BOXES", 100),
UserDailyBoxes: envInt("WARPBOX_USER_DAILY_BOXES", 250),
AnonymousActiveBoxes: envInt("WARPBOX_ANONYMOUS_ACTIVE_BOXES", 500),
UserActiveBoxes: envInt("WARPBOX_USER_ACTIVE_BOXES", 1000),
ShortWindowRequests: envInt("WARPBOX_SHORT_WINDOW_REQUESTS", 60),
ShortWindowSeconds: envInt("WARPBOX_SHORT_WINDOW_SECONDS", 60),
AnonymousStorageBackend: envString("WARPBOX_ANONYMOUS_STORAGE_BACKEND", "local"),
UserStorageBackend: envString("WARPBOX_USER_STORAGE_BACKEND", "local"),
}, },
} }
@@ -79,7 +101,16 @@ func Load() (Config, error) {
cfg.DefaultSettings.AnonymousDailyUploadMB <= 0 || cfg.DefaultSettings.AnonymousDailyUploadMB <= 0 ||
cfg.DefaultSettings.UserDailyUploadMB <= 0 || cfg.DefaultSettings.UserDailyUploadMB <= 0 ||
cfg.DefaultSettings.DefaultUserStorageMB <= 0 || cfg.DefaultSettings.DefaultUserStorageMB <= 0 ||
cfg.DefaultSettings.UsageRetentionDays <= 0 { cfg.DefaultSettings.UsageRetentionDays <= 0 ||
cfg.DefaultSettings.LocalStorageMaxGB <= 0 ||
cfg.DefaultSettings.AnonymousMaxDays <= 0 ||
cfg.DefaultSettings.UserMaxDays <= 0 ||
cfg.DefaultSettings.AnonymousDailyBoxes <= 0 ||
cfg.DefaultSettings.UserDailyBoxes <= 0 ||
cfg.DefaultSettings.AnonymousActiveBoxes <= 0 ||
cfg.DefaultSettings.UserActiveBoxes <= 0 ||
cfg.DefaultSettings.ShortWindowRequests <= 0 ||
cfg.DefaultSettings.ShortWindowSeconds <= 0 {
return Config{}, fmt.Errorf("upload policy settings must be positive") return Config{}, fmt.Errorf("upload policy settings must be positive")
} }
@@ -172,6 +203,23 @@ func envMegabytesFloat(key string, fallback float64) float64 {
return parsed return parsed
} }
func envGigabytesFloat(key string, fallback float64) float64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
normalized := strings.TrimSpace(value)
normalized = strings.TrimSuffix(normalized, "GB")
normalized = strings.TrimSuffix(normalized, "Gb")
normalized = strings.TrimSuffix(normalized, "gb")
normalized = strings.TrimSpace(normalized)
parsed, err := strconv.ParseFloat(normalized, 64)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func parseMegabytes(value string) (int64, error) { func parseMegabytes(value string) (int64, error) {
sizeMB, err := parseMegabytesFloat(value) sizeMB, err := parseMegabytesFloat(value)
if err != nil { if err != nil {

View File

@@ -269,6 +269,104 @@ func TestSignedInDailyCap(t *testing.T) {
} }
} }
func TestLayeredUploadLimits(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousDailyBoxes = 1
policy.AnonymousActiveBoxes = 10
policy.AnonymousMaxDays = 3
policy.LocalStorageMaxGB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
first := uploadThroughApp(t, app)
if first.BoxID == "" {
t.Fatalf("first upload did not return a box id")
}
secondRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "second.txt", "hello")
secondRequest.Header.Set("Accept", "application/json")
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, secondRequest)
if secondResponse.Code != http.StatusTooManyRequests {
t.Fatalf("daily box status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
policy.AnonymousDailyBoxes = 10
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
expiryRequest := multipartUploadRequestWithField(t, "/api/v1/upload", "file", "expiry.txt", "hello", "max_days", "30")
expiryRequest.Header.Set("Accept", "application/json")
expiryResponse := httptest.NewRecorder()
app.Upload(expiryResponse, expiryRequest)
if expiryResponse.Code != http.StatusRequestEntityTooLarge && expiryResponse.Code != http.StatusTooManyRequests {
t.Fatalf("expiry/box status = %d, body = %s", expiryResponse.Code, expiryResponse.Body.String())
}
}
func TestUserPolicyOverrideChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
dailyBoxes := 1
maxDays := 1
if err := app.authService.SetUserPolicy(user.ID, services.UserPolicy{DailyBoxes: &dailyBoxes, MaxDays: &maxDays}); err != nil {
t.Fatalf("SetUserPolicy returned error: %v", err)
}
_, token, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
first := multipartUploadRequest(t, "/api/v1/upload", "file", "one.txt", "hello")
first.Header.Set("Accept", "application/json")
first.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
firstResponse := httptest.NewRecorder()
app.Upload(firstResponse, first)
if firstResponse.Code != http.StatusCreated {
t.Fatalf("first status = %d, body = %s", firstResponse.Code, firstResponse.Body.String())
}
second := multipartUploadRequest(t, "/api/v1/upload", "file", "two.txt", "hello")
second.Header.Set("Accept", "application/json")
second.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
secondResponse := httptest.NewRecorder()
app.Upload(secondResponse, second)
if secondResponse.Code != http.StatusTooManyRequests {
t.Fatalf("second status = %d, body = %s", secondResponse.Code, secondResponse.Body.String())
}
}
func TestLocalStorageCapRejectsUpload(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 4
policy.AnonymousDailyUploadMB = 8
policy.LocalStorageMaxGB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", 2*1024*1024))
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("storage cap status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) { func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
@@ -281,10 +379,11 @@ func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
t.Fatalf("Login returned error: %v", err) t.Fatalf("Login returned error: %v", err)
} }
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30") settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30&csrf_token=test-csrf")
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm) settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token}) settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
settingsResponse := httptest.NewRecorder() settingsResponse := httptest.NewRecorder()
app.AdminSettingsPost(settingsResponse, settingsRequest) app.AdminSettingsPost(settingsResponse, settingsRequest)
if settingsResponse.Code != http.StatusSeeOther { if settingsResponse.Code != http.StatusSeeOther {
@@ -320,9 +419,10 @@ func TestAdminUserQuotaPostChangesEnforcement(t *testing.T) {
t.Fatalf("admin Login returned error: %v", err) t.Fatalf("admin Login returned error: %v", err)
} }
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001")) quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001&csrf_token=test-csrf"))
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken}) quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
quotaRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
quotaRequest.SetPathValue("userID", user.ID) quotaRequest.SetPathValue("userID", user.ID)
quotaResponse := httptest.NewRecorder() quotaResponse := httptest.NewRecorder()
app.AdminUpdateUserQuota(quotaResponse, quotaRequest) app.AdminUpdateUserQuota(quotaResponse, quotaRequest)

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"net/http" "net/http"
@@ -19,6 +20,8 @@ type adminPageData struct {
Boxes []adminBoxView Boxes []adminBoxView
Users []adminUserView Users []adminUserView
Settings services.UploadPolicySettings Settings services.UploadPolicySettings
Storage []services.StorageBackendView
UserEdit adminUserEditView
Section string Section string
PageTitle string PageTitle string
LastInviteURL string LastInviteURL string
@@ -39,15 +42,40 @@ type adminBoxView struct {
} }
type adminUserView struct { type adminUserView struct {
ID string ID string
Username string Username string
Email string Email string
Role string Role string
Status string Status string
StorageUsed string StorageUsed string
StorageQuota string StorageQuota string
DailyUsed string DailyUsed string
CreatedAt string StorageBackend string
CreatedAt string
}
type adminUserEditView struct {
ID string
Username string
Email string
Role string
Status string
StorageUsed string
DailyUsed string
EffectiveStorage string
EffectiveDaily string
EffectiveMaxDays int
EffectiveDailyBoxes int
EffectiveActiveBoxes int
EffectiveBackend string
MaxUploadMB string
DailyUploadMB string
StorageQuotaMB string
MaxDays string
DailyBoxes string
ActiveBoxes string
ShortWindowRequests string
StorageBackendID string
} }
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
@@ -59,6 +87,10 @@ func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("admin-login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAdminLogin(w, r, http.StatusTooManyRequests, "Too many admin login attempts.")
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
a.renderAdminLogin(w, r, http.StatusBadRequest, "Unable to read login form.") a.renderAdminLogin(w, r, http.StatusBadRequest, "Unable to read login form.")
return return
@@ -83,6 +115,9 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
if !a.validateCSRF(w, r) {
return
}
a.clearUserSessionCookie(w) a.clearUserSessionCookie(w)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: adminCookieName, Name: adminCookieName,
@@ -176,20 +211,22 @@ func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
for _, user := range users { for _, user := range users {
storageUsed, _ := a.uploadService.UserActiveStorageUsed(user.ID) storageUsed, _ := a.uploadService.UserActiveStorageUsed(user.ID)
usage, _ := a.settingsService.UsageForUser(user.ID, time.Now().UTC()) usage, _ := a.settingsService.UsageForUser(user.ID, time.Now().UTC())
quotaMB := settings.DefaultUserStorageMB policy := a.settingsService.EffectivePolicyForUser(settings, user)
if user.StorageQuotaMB != nil { quota := "unlimited"
quotaMB = *user.StorageQuotaMB if policy.StorageQuotaSet {
quota = formatMB(policy.StorageQuotaMB)
} }
rows = append(rows, adminUserView{ rows = append(rows, adminUserView{
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
Role: user.Role, Role: user.Role,
Status: user.Status, Status: user.Status,
StorageUsed: services.FormatMegabytesFromBytes(storageUsed), StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
StorageQuota: formatMB(quotaMB), StorageQuota: quota,
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes), DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"), StorageBackend: policy.StorageBackendID,
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
}) })
} }
a.renderPage(w, r, http.StatusOK, "admin_users.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "admin_users.html", web.PageData{
@@ -206,6 +243,45 @@ func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (a *App) AdminEditUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
user, err := a.authService.UserByID(r.PathValue("userID"))
if err != nil {
http.NotFound(w, r)
return
}
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
storage, err := a.storageBackendViews()
if err != nil {
http.Error(w, "unable to load storage", http.StatusInternalServerError)
return
}
edit, err := a.adminUserEdit(user, settings)
if err != nil {
http.Error(w, "unable to load user policy", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_user_edit.html", web.PageData{
Title: "Edit user",
Description: "Edit a Warpbox user.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
UserEdit: edit,
Storage: storage,
Section: "users",
PageTitle: "Edit user",
LastInviteURL: r.URL.Query().Get("invite"),
Error: r.URL.Query().Get("error"),
},
})
}
func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) { func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) {
return return
@@ -215,12 +291,18 @@ func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to load settings", http.StatusInternalServerError) http.Error(w, "unable to load settings", http.StatusInternalServerError)
return return
} }
storage, err := a.storageBackendViews()
if err != nil {
http.Error(w, "unable to load storage", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_settings.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "admin_settings.html", web.PageData{
Title: "Admin settings", Title: "Admin settings",
Description: "Manage Warpbox upload policy.", Description: "Manage Warpbox upload policy.",
CurrentUser: a.currentPublicUser(r), CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Settings: settings, Settings: settings,
Storage: storage,
Section: "settings", Section: "settings",
PageTitle: "Settings", PageTitle: "Settings",
}, },
@@ -228,18 +310,55 @@ func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) { func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
return return
} }
settings := services.UploadPolicySettings{ settings, err := a.settingsService.UploadPolicy()
AnonymousUploadsEnabled: r.FormValue("anonymous_uploads_enabled") == "on", if err != nil {
UsageRetentionDays: parsePositiveInt(r.FormValue("usage_retention_days")), http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
settings.AnonymousUploadsEnabled = r.FormValue("anonymous_uploads_enabled") == "on"
if value := parsePositiveInt(r.FormValue("usage_retention_days")); value > 0 {
settings.UsageRetentionDays = value
}
if value := parsePositiveFloat(r.FormValue("local_storage_max_gb")); value > 0 {
settings.LocalStorageMaxGB = value
}
if value := parsePositiveInt(r.FormValue("anonymous_max_days")); value > 0 {
settings.AnonymousMaxDays = value
}
if value := parsePositiveInt(r.FormValue("user_max_days")); value > 0 {
settings.UserMaxDays = value
}
if value := parsePositiveInt(r.FormValue("anonymous_daily_boxes")); value > 0 {
settings.AnonymousDailyBoxes = value
}
if value := parsePositiveInt(r.FormValue("user_daily_boxes")); value > 0 {
settings.UserDailyBoxes = value
}
if value := parsePositiveInt(r.FormValue("anonymous_active_boxes")); value > 0 {
settings.AnonymousActiveBoxes = value
}
if value := parsePositiveInt(r.FormValue("user_active_boxes")); value > 0 {
settings.UserActiveBoxes = value
}
if value := parsePositiveInt(r.FormValue("short_window_requests")); value > 0 {
settings.ShortWindowRequests = value
}
if value := parsePositiveInt(r.FormValue("short_window_seconds")); value > 0 {
settings.ShortWindowSeconds = value
}
if value := r.FormValue("anonymous_storage_backend"); value != "" {
settings.AnonymousStorageBackend = value
}
if value := r.FormValue("user_storage_backend"); value != "" {
settings.UserStorageBackend = value
} }
var err error
if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_max_upload_mb")); err != nil { if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_max_upload_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -260,6 +379,14 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
http.Error(w, "usage retention days must be positive", http.StatusBadRequest) http.Error(w, "usage retention days must be positive", http.StatusBadRequest)
return return
} }
if _, err := a.uploadService.Storage().BackendConfig(settings.AnonymousStorageBackend); err != nil {
http.Error(w, "anonymous storage backend not found", http.StatusBadRequest)
return
}
if _, err := a.uploadService.Storage().BackendConfig(settings.UserStorageBackend); err != nil {
http.Error(w, "user storage backend not found", http.StatusBadRequest)
return
}
if err := a.settingsService.UpdateUploadPolicy(settings); err != nil { if err := a.settingsService.UpdateUploadPolicy(settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -267,10 +394,127 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther) http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
} }
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) { func (a *App) AdminStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) {
return return
} }
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
views, err := a.storageBackendViews()
if err != nil {
http.Error(w, "unable to load storage", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_storage.html", web.PageData{
Title: "Admin storage",
Description: "Manage Warpbox storage backends.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Settings: settings,
Storage: views,
Section: "storage",
PageTitle: "Storage",
Error: r.URL.Query().Get("error"),
},
})
}
func (a *App) AdminCreateS3Storage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
return
}
_, err := a.uploadService.Storage().CreateS3Backend(services.StorageBackendConfig{
Provider: r.FormValue("provider"),
Name: r.FormValue("name"),
Endpoint: r.FormValue("endpoint"),
Region: r.FormValue("region"),
Bucket: r.FormValue("bucket"),
AccessKey: r.FormValue("access_key"),
SecretKey: r.FormValue("secret_key"),
UseSSL: r.FormValue("use_ssl") == "on",
PathStyle: r.FormValue("path_style") == "on",
})
if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
return
}
_, err := a.uploadService.Storage().UpdateS3Backend(r.PathValue("backendID"), services.StorageBackendConfig{
Provider: r.FormValue("provider"),
Name: r.FormValue("name"),
Endpoint: r.FormValue("endpoint"),
Region: r.FormValue("region"),
Bucket: r.FormValue("bucket"),
AccessKey: r.FormValue("access_key"),
SecretKey: r.FormValue("secret_key"),
UseSSL: r.FormValue("use_ssl") == "on",
PathStyle: r.FormValue("path_style") == "on",
})
if err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if _, err := a.uploadService.Storage().TestBackend(r.PathValue("backendID")); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
id := r.PathValue("backendID")
inUse, _ := a.storageBackendInUse(id)
if err := a.uploadService.Storage().DisableBackend(id, inUse); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
id := r.PathValue("backendID")
inUse, _ := a.storageBackendInUse(id)
if err := a.uploadService.Storage().DeleteBackend(id, inUse); err != nil {
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return return
@@ -291,9 +535,99 @@ func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther) http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
} }
func (a *App) AdminUpdateUserPolicy(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
policy := services.UserPolicy{
MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")),
DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")),
StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")),
MaxDays: optionalInt(r.FormValue("max_days")),
DailyBoxes: optionalInt(r.FormValue("daily_boxes")),
ActiveBoxes: optionalInt(r.FormValue("active_boxes")),
ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")),
}
if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
http.Error(w, "storage backend not found", http.StatusBadRequest)
return
}
policy.StorageBackendID = &backendID
}
if err := a.authService.SetUserPolicy(r.PathValue("userID"), policy); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit", http.StatusSeeOther)
return
}
policy := services.UserPolicy{
MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")),
DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")),
StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")),
MaxDays: optionalInt(r.FormValue("max_days")),
DailyBoxes: optionalInt(r.FormValue("daily_boxes")),
ActiveBoxes: optionalInt(r.FormValue("active_boxes")),
ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")),
}
if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?error="+url.QueryEscape("storage backend not found"), http.StatusSeeOther)
return
}
policy.StorageBackendID = &backendID
}
if _, err := a.authService.UpdateUserAdminFields(
r.PathValue("userID"),
r.FormValue("username"),
r.FormValue("email"),
r.FormValue("role"),
r.FormValue("status"),
policy,
); err != nil {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserStorage(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
if backendID := r.FormValue("storage_backend_id"); backendID != "" {
if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil {
http.Error(w, "storage backend not found", http.StatusBadRequest)
return
}
}
if err := a.authService.SetUserStorageBackend(r.PathValue("userID"), r.FormValue("storage_backend_id")); err != nil {
http.Error(w, "unable to update user storage", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) { func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r) admin, ok := a.requireAdminUser(w, r)
if !ok { if !ok || !a.validateCSRF(w, r) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -310,7 +644,7 @@ func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) { func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return return
} }
disabled := r.URL.Query().Get("disabled") != "false" disabled := r.URL.Query().Get("disabled") != "false"
@@ -323,7 +657,7 @@ func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) { func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r) admin, ok := a.requireAdminUser(w, r)
if !ok { if !ok || !a.validateCSRF(w, r) {
return return
} }
result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID) result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID)
@@ -331,11 +665,15 @@ func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unable to create reset link", http.StatusInternalServerError) http.Error(w, "unable to create reset link", http.StatusInternalServerError)
return return
} }
if r.URL.Query().Get("next") == "edit" {
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther) http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
} }
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) { func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
return return
} }
@@ -471,6 +809,161 @@ func parsePositiveInt(value string) int {
return parsed return parsed
} }
func parsePositiveFloat(value string) float64 {
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return parsed
}
func optionalMB(value string) *float64 {
if value == "" {
return nil
}
parsed, err := services.ParseMegabytesValue(value)
if err != nil {
return nil
}
return &parsed
}
func optionalMBAllowZero(value string) *float64 {
if value == "" {
return nil
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 {
return nil
}
return &parsed
}
func optionalInt(value string) *int {
if value == "" {
return nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return nil
}
return &parsed
}
func formatMB(value float64) string { func formatMB(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) + " MB" return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
} }
func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
configs, err := a.uploadService.Storage().ListBackendConfigs()
if err != nil {
return nil, err
}
views := make([]services.StorageBackendView, 0, len(configs))
for _, cfg := range configs {
var usage int64
if backend, err := a.uploadService.Storage().BackendConfig(cfg.ID); err == nil && backend.Enabled {
if concrete, err := a.uploadService.Storage().Backend(cfg.ID); err == nil {
usage, _ = concrete.Usage(context.Background())
}
}
inUse, _ := a.storageBackendInUse(cfg.ID)
views = append(views, services.StorageBackendView{
Config: cfg,
UsageBytes: usage,
UsageLabel: services.FormatMegabytesFromBytes(usage),
InUse: inUse,
})
}
return views, nil
}
func (a *App) adminUserEdit(user services.User, settings services.UploadPolicySettings) (adminUserEditView, error) {
storageUsed, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil {
return adminUserEditView{}, err
}
usage, err := a.settingsService.UsageForUser(user.ID, time.Now().UTC())
if err != nil {
return adminUserEditView{}, err
}
effective := a.settingsService.EffectivePolicyForUser(settings, user)
view := adminUserEditView{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
EffectiveDaily: services.FormatMegabytesLabel(effective.DailyUploadMB),
EffectiveMaxDays: effective.MaxDays,
EffectiveDailyBoxes: effective.DailyBoxes,
EffectiveActiveBoxes: effective.ActiveBoxes,
EffectiveBackend: effective.StorageBackendID,
MaxUploadMB: floatPtrString(user.Policy.MaxUploadMB),
DailyUploadMB: floatPtrString(user.Policy.DailyUploadMB),
StorageQuotaMB: floatPtrString(user.Policy.StorageQuotaMB),
MaxDays: intPtrString(user.Policy.MaxDays),
DailyBoxes: intPtrString(user.Policy.DailyBoxes),
ActiveBoxes: intPtrString(user.Policy.ActiveBoxes),
ShortWindowRequests: intPtrString(user.Policy.ShortWindowRequests),
StorageBackendID: stringPtrString(user.Policy.StorageBackendID),
}
if effective.StorageQuotaSet {
view.EffectiveStorage = services.FormatMegabytesLabel(effective.StorageQuotaMB)
} else {
view.EffectiveStorage = "unlimited"
}
return view, nil
}
func (a *App) storageBackendInUse(id string) (bool, error) {
settings, err := a.settingsService.UploadPolicy()
if err != nil {
return false, err
}
if settings.AnonymousStorageBackend == id || settings.UserStorageBackend == id {
return true, nil
}
boxes, err := a.uploadService.ListBoxes(0)
if err != nil {
return false, err
}
for _, box := range boxes {
if a.uploadService.BoxStorageBackendID(box) == id {
return true, nil
}
}
users, err := a.authService.ListUsers()
if err != nil {
return false, err
}
for _, user := range users {
if user.Policy.StorageBackendID != nil && *user.Policy.StorageBackendID == id {
return true, nil
}
}
return false, nil
}
func floatPtrString(value *float64) string {
if value == nil {
return ""
}
return strconv.FormatFloat(*value, 'f', -1, 64)
}
func intPtrString(value *int) string {
if value == nil {
return ""
}
return strconv.Itoa(*value)
}
func stringPtrString(value *string) string {
if value == nil {
return ""
}
return *value
}

View File

@@ -16,6 +16,7 @@ type App struct {
uploadService *services.UploadService uploadService *services.UploadService
authService *services.AuthService authService *services.AuthService
settingsService *services.SettingsService settingsService *services.SettingsService
rateLimiter *rateLimiter
} }
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App { func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
@@ -26,6 +27,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
uploadService: uploadService, uploadService: uploadService,
authService: authService, authService: authService,
settingsService: settingsService, settingsService: settingsService,
rateLimiter: newRateLimiter(),
} }
} }
@@ -33,6 +35,7 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
if data.CurrentUser == nil { if data.CurrentUser == nil {
data.CurrentUser = a.currentPublicUser(r) data.CurrentUser = a.currentPublicUser(r)
} }
data.CSRFToken = a.csrfToken(w, r)
a.renderer.Render(w, status, page, data) a.renderer.Render(w, status, page, data)
} }
@@ -59,12 +62,22 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin", a.AdminDashboard) mux.HandleFunc("GET /admin", a.AdminDashboard)
mux.HandleFunc("GET /admin/files", a.AdminFiles) mux.HandleFunc("GET /admin/files", a.AdminFiles)
mux.HandleFunc("GET /admin/users", a.AdminUsers) mux.HandleFunc("GET /admin/users", a.AdminUsers)
mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser)
mux.HandleFunc("GET /admin/settings", a.AdminSettings) mux.HandleFunc("GET /admin/settings", a.AdminSettings)
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost) mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
mux.HandleFunc("GET /admin/storage", a.AdminStorage)
mux.HandleFunc("POST /admin/storage/s3", a.AdminCreateS3Storage)
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/disable", a.AdminDisableStorage)
mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage)
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite) mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser) mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser) mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota) mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
mux.HandleFunc("POST /admin/users/{userID}/edit", a.AdminUpdateUser)
mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy)
mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)

View File

@@ -33,6 +33,10 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) { func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
return return
@@ -55,6 +59,10 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) { func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
return
}
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
return return
@@ -75,6 +83,9 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) Logout(w http.ResponseWriter, r *http.Request) { func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
if !a.validateCSRF(w, r) {
return
}
if cookie, err := r.Cookie(userSessionCookieName); err == nil { if cookie, err := r.Cookie(userSessionCookieName); err == nil {
_ = a.authService.Logout(cookie.Value) _ = a.authService.Logout(cookie.Value)
} }
@@ -126,7 +137,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) { func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r) user, ok := a.requireUser(w, r)
if !ok { if !ok || !a.validateCSRF(w, r) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {

View File

@@ -104,7 +104,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) { func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r) user, ok := a.requireUser(w, r)
if !ok { if !ok || !a.validateCSRF(w, r) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -119,7 +119,7 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) { func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r) user, ok := a.requireUser(w, r)
if !ok { if !ok || !a.validateCSRF(w, r) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -135,7 +135,7 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) { func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r) user, ok := a.requireUser(w, r)
if !ok { if !ok || !a.validateCSRF(w, r) {
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -156,7 +156,7 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) { func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r) user, ok := a.requireUser(w, r)
if !ok { if !ok || !a.validateCSRF(w, r) {
return return
} }
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil { if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {

View File

@@ -1,8 +1,10 @@
package handlers package handlers
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -143,12 +145,15 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
return return
} }
path := a.uploadService.ThumbnailPath(box, file) object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
if path == "" { if err != nil {
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp")) http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "img", "file-placeholder.webp"))
return return
} }
http.ServeFile(w, r, path) defer object.Body.Close()
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
} }
func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) { func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
@@ -199,31 +204,40 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
} }
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) { func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
path := a.uploadService.FilePath(box, file) object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
source, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer source.Close()
stat, err := source.Stat()
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
defer object.Body.Close()
w.Header().Set("Content-Type", file.ContentType) w.Header().Set("Content-Type", file.ContentType)
if attachment { if attachment {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name)) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
} }
http.ServeContent(w, r, file.Name, stat.ModTime(), source) if seeker, ok := object.Body.(io.ReadSeeker); ok {
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
} else {
if object.Size > 0 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", object.Size))
}
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, object.Body)
}
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) { if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error()) a.logger.Warn("failed to record file download", "source", "download", "severity", "warn", "code", 4002, "box_id", box.ID, "error", err.Error())
} }
} }
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
data, err := io.ReadAll(source)
if err != nil {
return bytes.NewReader(nil)
}
return bytes.NewReader(data)
}
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
box, err := a.uploadService.GetBox(r.PathValue("boxID")) box, err := a.uploadService.GetBox(r.PathValue("boxID"))
if err != nil { if err != nil {

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
@@ -61,11 +62,16 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
if !settings.AnonymousUploadsEnabled { if !settings.AnonymousUploadsEnabled {
return "Anonymous uploads disabled", "Sign in to upload files." return "Anonymous uploads disabled", "Sign in to upload files."
} }
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP." return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
} }
quotaMB := settings.DefaultUserStorageMB policy := a.settingsService.EffectivePolicyForUser(settings, user)
if user.StorageQuotaMB != nil { maxUpload := a.uploadService.MaxUploadSizeLabel()
quotaMB = *user.StorageQuotaMB if policy.MaxUploadMB > 0 {
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
} }
return a.uploadService.MaxUploadSizeLabel(), "Daily cap: " + services.FormatMegabytesLabel(settings.UserDailyUploadMB) + " · Storage quota: " + services.FormatMegabytesLabel(quotaMB) + "." quota := "unlimited"
if policy.StorageQuotaSet {
quota = services.FormatMegabytesLabel(policy.StorageQuotaMB)
}
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max."
} }

View File

@@ -0,0 +1,78 @@
package handlers
import (
"crypto/rand"
"encoding/base64"
"net/http"
"sync"
"time"
)
const csrfCookieName = "warpbox_csrf"
type rateLimiter struct {
mu sync.Mutex
records map[string]rateRecord
}
type rateRecord struct {
StartedAt time.Time
Count int
}
func newRateLimiter() *rateLimiter {
return &rateLimiter{records: make(map[string]rateRecord)}
}
func (l *rateLimiter) Allow(key string, limit int, window time.Duration, now time.Time) bool {
if limit <= 0 || window <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
record := l.records[key]
if record.StartedAt.IsZero() || now.Sub(record.StartedAt) >= window {
l.records[key] = rateRecord{StartedAt: now, Count: 1}
return true
}
record.Count++
l.records[key] = record
return record.Count <= limit
}
func (a *App) csrfToken(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
return cookie.Value
}
token := randomToken(32)
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().Add(12 * time.Hour),
})
return token
}
func (a *App) validateCSRF(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
return true
}
cookie, err := r.Cookie(csrfCookieName)
if err != nil || cookie.Value == "" || r.FormValue("csrf_token") != cookie.Value {
http.Error(w, "invalid form token", http.StatusForbidden)
return false
}
return true
}
func randomToken(byteCount int) string {
data := make([]byte, byteCount)
if _, err := rand.Read(data); err != nil {
return base64.RawURLEncoding.EncodeToString([]byte(time.Now().String()))
}
return base64.RawURLEncoding.EncodeToString(data)
}

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"mime/multipart" "mime/multipart"
@@ -27,11 +28,17 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled") helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return return
} }
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
rateKey := uploadRateKey(r, user, loggedIn)
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
return
}
if !isAdminUpload { if !isAdminUpload {
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize())) r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize()))
} }
parseLimit := uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize()) parseLimit := uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize())
if isAdminUpload { if isAdminUpload {
parseLimit = 32 << 20 parseLimit = 32 << 20
} }
@@ -53,19 +60,29 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
} }
} }
if !isAdminUpload { if !isAdminUpload {
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, files, totalBytes); message != "" { if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message) helpers.WriteJSONError(w, status, message)
return return
} }
} }
maxDays := parseInt(r.FormValue("max_days"))
if maxDays <= 0 {
maxDays = min(7, effectivePolicy.MaxDays)
}
if !isAdminUpload && maxDays > effectivePolicy.MaxDays {
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
return
}
result, err := a.uploadService.CreateBox(files, services.UploadOptions{ result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")), MaxDays: maxDays,
MaxDownloads: parseInt(r.FormValue("max_downloads")), MaxDownloads: parseInt(r.FormValue("max_downloads")),
Password: r.FormValue("password"), Password: r.FormValue("password"),
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on", ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
OwnerID: ownerID, OwnerID: ownerID,
CollectionID: collectionID, CollectionID: collectionID,
SkipSizeLimit: isAdminUpload, SkipSizeLimit: isAdminUpload,
CreatorIP: uploadClientIP(r),
StorageBackendID: effectivePolicy.StorageBackendID,
}) })
if err != nil { if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error()) a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
@@ -73,7 +90,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return return
} }
if !isAdminUpload { if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes); err != nil { if err := a.recordUploadUsage(r, user, loggedIn, totalBytes, 1); err != nil {
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error()) a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
} }
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil { if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
@@ -92,25 +109,40 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, result.BoxURL) _, _ = fmt.Fprintln(w, result.BoxURL)
} }
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, files []*multipart.FileHeader, totalBytes int64) (int, string) { func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
if len(files) == 0 { if len(files) == 0 {
return 0, "" return 0, ""
} }
now := time.Now().UTC() now := time.Now().UTC()
if !loggedIn { if policy.MaxUploadMB > 0 {
anonymousMaxBytes := services.MegabytesToBytes(settings.AnonymousMaxUploadMB) maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
for _, file := range files { for _, file := range files {
if file.Size > anonymousMaxBytes { if file.Size > maxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds anonymous upload size limit" return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
} }
} }
}
if !loggedIn {
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now) usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
if err != nil { if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked" return http.StatusInternalServerError, "upload usage could not be checked"
} }
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.AnonymousDailyUploadMB) { if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "anonymous daily upload limit reached" return http.StatusTooManyRequests, "anonymous daily upload limit reached"
} }
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "anonymous daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r))
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "anonymous active box limit reached"
}
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
return status, message
}
return 0, "" return 0, ""
} }
@@ -118,42 +150,86 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo
if err != nil { if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked" return http.StatusInternalServerError, "upload usage could not be checked"
} }
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.UserDailyUploadMB) { if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) {
return http.StatusTooManyRequests, "daily upload limit reached" return http.StatusTooManyRequests, "daily upload limit reached"
} }
if usage.UploadedBoxes+1 > policy.DailyBoxes {
return http.StatusTooManyRequests, "daily box limit reached"
}
activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID)
if err != nil {
return http.StatusInternalServerError, "active box limit could not be checked"
}
if activeBoxes+1 > policy.ActiveBoxes {
return http.StatusTooManyRequests, "active box limit reached"
}
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID) activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil { if err != nil {
return http.StatusInternalServerError, "storage quota could not be checked" return http.StatusInternalServerError, "storage quota could not be checked"
} }
quotaMB := settings.DefaultUserStorageMB if policy.StorageQuotaSet && activeStorage+totalBytes > services.MegabytesToBytes(policy.StorageQuotaMB) {
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
}
if activeStorage+totalBytes > services.MegabytesToBytes(quotaMB) {
return http.StatusRequestEntityTooLarge, "storage quota reached" return http.StatusRequestEntityTooLarge, "storage quota reached"
} }
if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" {
return status, message
}
return 0, "" return 0, ""
} }
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64) error { func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64, boxes int) error {
now := time.Now().UTC() now := time.Now().UTC()
if loggedIn { if loggedIn {
return a.settingsService.AddUsage("user", user.ID, totalBytes, now) return a.settingsService.AddUploadUsage("user", user.ID, totalBytes, boxes, now)
} }
return a.settingsService.AddUsage("ip", uploadClientIP(r), totalBytes, now) return a.settingsService.AddUploadUsage("ip", uploadClientIP(r), totalBytes, boxes, now)
} }
func uploadParseLimit(settings services.UploadPolicySettings, loggedIn bool, fallback int64) int64 { func (a *App) effectiveUploadPolicy(settings services.UploadPolicySettings, user services.User, loggedIn bool) services.EffectiveUploadPolicy {
if loggedIn { if loggedIn {
return a.settingsService.EffectivePolicyForUser(settings, user)
}
return a.settingsService.EffectivePolicyForAnonymous(settings)
}
func (a *App) checkStorageBackendCapacity(backendID string, settings services.UploadPolicySettings, totalBytes int64) (int, string) {
if backendID != services.StorageBackendLocal {
return 0, ""
}
backend, err := a.uploadService.Storage().Backend(services.StorageBackendLocal)
if err != nil {
return http.StatusInternalServerError, "storage backend could not be checked"
}
used, err := backend.Usage(context.Background())
if err != nil {
return http.StatusInternalServerError, "storage backend usage could not be checked"
}
if used+totalBytes > services.GigabytesToBytes(settings.LocalStorageMaxGB) {
return http.StatusRequestEntityTooLarge, "local storage limit reached"
}
return 0, ""
}
func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 {
if loggedIn && policy.MaxUploadMB <= 0 {
return fallback * 8 return fallback * 8
} }
return services.MegabytesToBytes(settings.AnonymousMaxUploadMB) * 8 if policy.MaxUploadMB > 0 {
return services.MegabytesToBytes(policy.MaxUploadMB) * 8
}
return fallback * 8
} }
func uploadClientIP(r *http.Request) string { func uploadClientIP(r *http.Request) string {
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")) return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
} }
func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
if loggedIn {
return "user:" + user.ID
}
return "ip:" + uploadClientIP(r)
}
func totalUploadBytes(files []*multipart.FileHeader) int64 { func totalUploadBytes(files []*multipart.FileHeader) int64 {
var total int64 var total int64
for _, file := range files { for _, file := range files {

View File

@@ -255,6 +255,29 @@ func multipartUploadRequest(t *testing.T, path, field, filename, body string) *h
return request return request
} }
func multipartUploadRequestWithField(t *testing.T, path, field, filename, body, extraName, extraValue string) *http.Request {
t.Helper()
var payload bytes.Buffer
writer := multipart.NewWriter(&payload)
part, err := writer.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("CreateFormFile returned error: %v", err)
}
if _, err := part.Write([]byte(body)); err != nil {
t.Fatalf("part.Write returned error: %v", err)
}
if err := writer.WriteField(extraName, extraValue); err != nil {
t.Fatalf("WriteField returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, path, &payload)
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
}
func tokenFromURL(t *testing.T, value string) string { func tokenFromURL(t *testing.T, value string) string {
t.Helper() t.Helper()
parts := strings.Split(strings.TrimRight(value, "/"), "/") parts := strings.Split(strings.TrimRight(value, "/"), "/")

View File

@@ -1,11 +1,14 @@
package jobs package jobs
import ( import (
"bytes"
"context"
"image" "image"
_ "image/gif" _ "image/gif"
"image/jpeg" "image/jpeg"
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"io"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
@@ -129,43 +132,71 @@ func needsThumbnail(file services.File) bool {
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
thumbnailName := "@thumb@" + file.ID + ".jpg" thumbnailName := "@thumb@" + file.ID + ".jpg"
thumbnailPath := uploadService.ThumbnailPath(box, services.File{Thumbnail: thumbnailName}) object, err := uploadService.OpenFileObject(context.Background(), box, file)
sourcePath := uploadService.FilePath(box, file) if err != nil {
return "", err
}
defer object.Body.Close()
switch { switch {
case strings.HasPrefix(file.ContentType, "image/"): case strings.HasPrefix(file.ContentType, "image/"):
return thumbnailName, createImageThumbnail(sourcePath, thumbnailPath) data, err := createImageThumbnail(object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
return thumbnailName, err
case strings.HasPrefix(file.ContentType, "video/"): case strings.HasPrefix(file.ContentType, "video/"):
return thumbnailName, createVideoThumbnail(sourcePath, thumbnailPath) data, err := createVideoThumbnail(object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
return thumbnailName, err
default: default:
return "", nil return "", nil
} }
} }
func createImageThumbnail(sourcePath, targetPath string) error { func createImageThumbnail(source io.Reader) ([]byte, error) {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer source.Close()
img, _, err := image.Decode(source) img, _, err := image.Decode(source)
if err != nil { if err != nil {
return err return nil, err
} }
thumb := resizeNearest(img, 360, 240) thumb := resizeNearest(img, 360, 240)
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) var target bytes.Buffer
err = jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82})
if err != nil { if err != nil {
return err return nil, err
} }
defer target.Close() return target.Bytes(), nil
return jpeg.Encode(target, thumb, &jpeg.Options{Quality: 82})
} }
func createVideoThumbnail(sourcePath, targetPath string) error { func createVideoThumbnail(source io.Reader) ([]byte, error) {
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourcePath, "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run() sourceFile, err := os.CreateTemp("", "warpbox-video-*")
if err != nil {
return nil, err
}
defer os.Remove(sourceFile.Name())
if _, err := io.Copy(sourceFile, source); err != nil {
sourceFile.Close()
return nil, err
}
if err := sourceFile.Close(); err != nil {
return nil, err
}
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
if err != nil {
return nil, err
}
targetPath := targetFile.Name()
targetFile.Close()
defer os.Remove(targetPath)
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil {
return nil, err
}
return os.ReadFile(targetPath)
} }
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA { func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {

View File

@@ -48,15 +48,27 @@ type AuthService struct {
} }
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"passwordHash"` PasswordHash string `json:"passwordHash"`
Role string `json:"role"` Role string `json:"role"`
Status string `json:"status"` Status string `json:"status"`
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"` StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
CreatedAt time.Time `json:"createdAt"` Policy UserPolicy `json:"policy,omitempty"`
UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type UserPolicy struct {
MaxUploadMB *float64 `json:"maxUploadMb,omitempty"`
DailyUploadMB *float64 `json:"dailyUploadMb,omitempty"`
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
MaxDays *int `json:"maxDays,omitempty"`
DailyBoxes *int `json:"dailyBoxes,omitempty"`
ActiveBoxes *int `json:"activeBoxes,omitempty"`
ShortWindowRequests *int `json:"shortWindowRequests,omitempty"`
StorageBackendID *string `json:"storageBackendId,omitempty"`
} }
type PublicUser struct { type PublicUser struct {
@@ -66,6 +78,7 @@ type PublicUser struct {
Role string Role string
Status string Status string
StorageQuotaMB *float64 StorageQuotaMB *float64
Policy UserPolicy
CreatedAt time.Time CreatedAt time.Time
} }
@@ -381,6 +394,97 @@ func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error
return s.saveUser(user) return s.saveUser(user)
} }
func (s *AuthService) SetUserPolicy(userID string, policy UserPolicy) error {
if err := validateUserPolicy(policy); err != nil {
return err
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.Policy = policy
user.StorageQuotaMB = policy.StorageQuotaMB
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) SetUserStorageBackend(userID, backendID string) error {
user, err := s.UserByID(userID)
if err != nil {
return err
}
backendID = strings.TrimSpace(backendID)
if backendID == "" {
user.Policy.StorageBackendID = nil
} else {
user.Policy.StorageBackendID = &backendID
}
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) {
if err := validateUserPolicy(policy); err != nil {
return User{}, err
}
username = strings.TrimSpace(username)
if username == "" {
return User{}, fmt.Errorf("username is required")
}
email, err := normalizeEmail(email)
if err != nil {
return User{}, err
}
if role != UserRoleAdmin && role != UserRoleUser {
return User{}, fmt.Errorf("invalid role")
}
if status != UserStatusActive && status != UserStatusDisabled {
return User{}, fmt.Errorf("invalid status")
}
var updated User
err = s.db.Update(func(tx *bbolt.Tx) error {
users := tx.Bucket(usersBucket)
emails := tx.Bucket(userEmailsBucket)
data := users.Get([]byte(userID))
if data == nil {
return os.ErrNotExist
}
var user User
if err := json.Unmarshal(data, &user); err != nil {
return err
}
if existing := emails.Get([]byte(email)); existing != nil && string(existing) != user.ID {
return fmt.Errorf("email is already registered")
}
if user.Email != email {
if err := emails.Delete([]byte(user.Email)); err != nil {
return err
}
if err := emails.Put([]byte(email), []byte(user.ID)); err != nil {
return err
}
}
user.Username = username
user.Email = email
user.Role = role
user.Status = status
user.Policy = policy
user.StorageQuotaMB = policy.StorageQuotaMB
user.UpdatedAt = time.Now().UTC()
next, err := json.Marshal(user)
if err != nil {
return err
}
if err := users.Put([]byte(user.ID), next); err != nil {
return err
}
updated = user
return nil
})
return updated, err
}
func (s *AuthService) UserByID(id string) (User, error) { func (s *AuthService) UserByID(id string) (User, error) {
var user User var user User
err := s.db.View(func(tx *bbolt.Tx) error { err := s.db.View(func(tx *bbolt.Tx) error {
@@ -476,6 +580,7 @@ func (s *AuthService) PublicUser(user User) PublicUser {
Role: user.Role, Role: user.Role,
Status: user.Status, Status: user.Status,
StorageQuotaMB: user.StorageQuotaMB, StorageQuotaMB: user.StorageQuotaMB,
Policy: user.Policy,
CreatedAt: user.CreatedAt, CreatedAt: user.CreatedAt,
} }
} }
@@ -593,3 +698,28 @@ func VerifyPasswordHash(encoded, password string) bool {
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected))) actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1 return subtle.ConstantTimeCompare(actual, expected) == 1
} }
func validateUserPolicy(policy UserPolicy) error {
if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 {
return fmt.Errorf("max upload override cannot be negative")
}
if policy.DailyUploadMB != nil && *policy.DailyUploadMB <= 0 {
return fmt.Errorf("daily upload override must be positive")
}
if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 {
return fmt.Errorf("storage quota override cannot be negative")
}
if policy.MaxDays != nil && *policy.MaxDays <= 0 {
return fmt.Errorf("expiration override must be positive")
}
if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 {
return fmt.Errorf("daily box override must be positive")
}
if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 {
return fmt.Errorf("active box override must be positive")
}
if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 {
return fmt.Errorf("short-window request override must be positive")
}
return nil
}

View File

@@ -26,6 +26,17 @@ type UploadPolicySettings struct {
UserDailyUploadMB float64 `json:"userDailyUploadMb"` UserDailyUploadMB float64 `json:"userDailyUploadMb"`
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"` DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
UsageRetentionDays int `json:"usageRetentionDays"` UsageRetentionDays int `json:"usageRetentionDays"`
LocalStorageMaxGB float64 `json:"localStorageMaxGb"`
AnonymousMaxDays int `json:"anonymousMaxDays"`
UserMaxDays int `json:"userMaxDays"`
AnonymousDailyBoxes int `json:"anonymousDailyBoxes"`
UserDailyBoxes int `json:"userDailyBoxes"`
AnonymousActiveBoxes int `json:"anonymousActiveBoxes"`
UserActiveBoxes int `json:"userActiveBoxes"`
ShortWindowRequests int `json:"shortWindowRequests"`
ShortWindowSeconds int `json:"shortWindowSeconds"`
AnonymousStorageBackend string `json:"anonymousStorageBackend"`
UserStorageBackend string `json:"userStorageBackend"`
} }
type UsageRecord struct { type UsageRecord struct {
@@ -34,9 +45,24 @@ type UsageRecord struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Date string `json:"date"` Date string `json:"date"`
UploadedBytes int64 `json:"uploadedBytes"` UploadedBytes int64 `json:"uploadedBytes"`
UploadedBoxes int `json:"uploadedBoxes"`
RequestCount int `json:"requestCount"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
type EffectiveUploadPolicy struct {
MaxUploadMB float64
DailyUploadMB float64
StorageQuotaMB float64
MaxDays int
DailyBoxes int
ActiveBoxes int
ShortRequests int
ShortWindow time.Duration
StorageBackendID string
StorageQuotaSet bool
}
type SettingsService struct { type SettingsService struct {
db *bbolt.DB db *bbolt.DB
defaults UploadPolicySettings defaults UploadPolicySettings
@@ -52,8 +78,20 @@ func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*Settin
UserDailyUploadMB: defaults.UserDailyUploadMB, UserDailyUploadMB: defaults.UserDailyUploadMB,
DefaultUserStorageMB: defaults.DefaultUserStorageMB, DefaultUserStorageMB: defaults.DefaultUserStorageMB,
UsageRetentionDays: defaults.UsageRetentionDays, UsageRetentionDays: defaults.UsageRetentionDays,
LocalStorageMaxGB: defaults.LocalStorageMaxGB,
AnonymousMaxDays: defaults.AnonymousMaxDays,
UserMaxDays: defaults.UserMaxDays,
AnonymousDailyBoxes: defaults.AnonymousDailyBoxes,
UserDailyBoxes: defaults.UserDailyBoxes,
AnonymousActiveBoxes: defaults.AnonymousActiveBoxes,
UserActiveBoxes: defaults.UserActiveBoxes,
ShortWindowRequests: defaults.ShortWindowRequests,
ShortWindowSeconds: defaults.ShortWindowSeconds,
AnonymousStorageBackend: defaults.AnonymousStorageBackend,
UserStorageBackend: defaults.UserStorageBackend,
}, },
} }
service.defaults = service.withBuiltinDefaultGaps(service.defaults)
if err := service.validate(service.defaults); err != nil { if err := service.validate(service.defaults); err != nil {
return nil, err return nil, err
} }
@@ -71,6 +109,43 @@ func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*Settin
return service, nil return service, nil
} }
func (s *SettingsService) withBuiltinDefaultGaps(settings UploadPolicySettings) UploadPolicySettings {
if settings.LocalStorageMaxGB <= 0 {
settings.LocalStorageMaxGB = 100
}
if settings.AnonymousMaxDays <= 0 {
settings.AnonymousMaxDays = 30
}
if settings.UserMaxDays <= 0 {
settings.UserMaxDays = 90
}
if settings.AnonymousDailyBoxes <= 0 {
settings.AnonymousDailyBoxes = 100
}
if settings.UserDailyBoxes <= 0 {
settings.UserDailyBoxes = 250
}
if settings.AnonymousActiveBoxes <= 0 {
settings.AnonymousActiveBoxes = 500
}
if settings.UserActiveBoxes <= 0 {
settings.UserActiveBoxes = 1000
}
if settings.ShortWindowRequests <= 0 {
settings.ShortWindowRequests = 60
}
if settings.ShortWindowSeconds <= 0 {
settings.ShortWindowSeconds = 60
}
if strings.TrimSpace(settings.AnonymousStorageBackend) == "" {
settings.AnonymousStorageBackend = StorageBackendLocal
}
if strings.TrimSpace(settings.UserStorageBackend) == "" {
settings.UserStorageBackend = StorageBackendLocal
}
return settings
}
func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) { func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
settings := s.defaults settings := s.defaults
err := s.db.View(func(tx *bbolt.Tx) error { err := s.db.View(func(tx *bbolt.Tx) error {
@@ -78,7 +153,11 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
if data == nil { if data == nil {
return nil return nil
} }
return json.Unmarshal(data, &settings) if err := json.Unmarshal(data, &settings); err != nil {
return err
}
settings = s.withDefaultGaps(settings)
return nil
}) })
if err != nil { if err != nil {
return UploadPolicySettings{}, err return UploadPolicySettings{}, err
@@ -89,6 +168,58 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
return settings, nil return settings, nil
} }
func (s *SettingsService) withDefaultGaps(settings UploadPolicySettings) UploadPolicySettings {
if settings.AnonymousMaxUploadMB <= 0 {
settings.AnonymousMaxUploadMB = s.defaults.AnonymousMaxUploadMB
}
if settings.AnonymousDailyUploadMB <= 0 {
settings.AnonymousDailyUploadMB = s.defaults.AnonymousDailyUploadMB
}
if settings.UserDailyUploadMB <= 0 {
settings.UserDailyUploadMB = s.defaults.UserDailyUploadMB
}
if settings.DefaultUserStorageMB <= 0 {
settings.DefaultUserStorageMB = s.defaults.DefaultUserStorageMB
}
if settings.UsageRetentionDays <= 0 {
settings.UsageRetentionDays = s.defaults.UsageRetentionDays
}
if settings.LocalStorageMaxGB <= 0 {
settings.LocalStorageMaxGB = s.defaults.LocalStorageMaxGB
}
if settings.AnonymousMaxDays <= 0 {
settings.AnonymousMaxDays = s.defaults.AnonymousMaxDays
}
if settings.UserMaxDays <= 0 {
settings.UserMaxDays = s.defaults.UserMaxDays
}
if settings.AnonymousDailyBoxes <= 0 {
settings.AnonymousDailyBoxes = s.defaults.AnonymousDailyBoxes
}
if settings.UserDailyBoxes <= 0 {
settings.UserDailyBoxes = s.defaults.UserDailyBoxes
}
if settings.AnonymousActiveBoxes <= 0 {
settings.AnonymousActiveBoxes = s.defaults.AnonymousActiveBoxes
}
if settings.UserActiveBoxes <= 0 {
settings.UserActiveBoxes = s.defaults.UserActiveBoxes
}
if settings.ShortWindowRequests <= 0 {
settings.ShortWindowRequests = s.defaults.ShortWindowRequests
}
if settings.ShortWindowSeconds <= 0 {
settings.ShortWindowSeconds = s.defaults.ShortWindowSeconds
}
if strings.TrimSpace(settings.AnonymousStorageBackend) == "" {
settings.AnonymousStorageBackend = s.defaults.AnonymousStorageBackend
}
if strings.TrimSpace(settings.UserStorageBackend) == "" {
settings.UserStorageBackend = s.defaults.UserStorageBackend
}
return settings
}
func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error { func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error {
if err := s.validate(settings); err != nil { if err := s.validate(settings); err != nil {
return err return err
@@ -117,7 +248,17 @@ func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (Usa
} }
func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error { func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error {
return s.AddUploadUsage(subjectType, subject, bytes, 0, now)
}
func (s *SettingsService) AddUploadUsage(subjectType, subject string, bytes int64, boxes int, now time.Time) error {
if bytes <= 0 { if bytes <= 0 {
bytes = 0
}
if boxes < 0 {
boxes = 0
}
if bytes == 0 && boxes == 0 {
return nil return nil
} }
key := usageKey(subjectType, subject, now) key := usageKey(subjectType, subject, now)
@@ -131,6 +272,7 @@ func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now
} }
} }
record.UploadedBytes += bytes record.UploadedBytes += bytes
record.UploadedBoxes += boxes
record.UpdatedAt = now.UTC() record.UpdatedAt = now.UTC()
next, err := json.Marshal(record) next, err := json.Marshal(record)
if err != nil { if err != nil {
@@ -140,6 +282,63 @@ func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now
}) })
} }
func (s *SettingsService) EffectivePolicyForAnonymous(settings UploadPolicySettings) EffectiveUploadPolicy {
return EffectiveUploadPolicy{
MaxUploadMB: settings.AnonymousMaxUploadMB,
DailyUploadMB: settings.AnonymousDailyUploadMB,
MaxDays: settings.AnonymousMaxDays,
DailyBoxes: settings.AnonymousDailyBoxes,
ActiveBoxes: settings.AnonymousActiveBoxes,
ShortRequests: settings.ShortWindowRequests,
ShortWindow: time.Duration(settings.ShortWindowSeconds) * time.Second,
StorageBackendID: normalizeBackendID(settings.AnonymousStorageBackend),
}
}
func (s *SettingsService) EffectivePolicyForUser(settings UploadPolicySettings, user User) EffectiveUploadPolicy {
policy := EffectiveUploadPolicy{
MaxUploadMB: 0,
DailyUploadMB: settings.UserDailyUploadMB,
StorageQuotaMB: settings.DefaultUserStorageMB,
MaxDays: settings.UserMaxDays,
DailyBoxes: settings.UserDailyBoxes,
ActiveBoxes: settings.UserActiveBoxes,
ShortRequests: settings.ShortWindowRequests,
ShortWindow: time.Duration(settings.ShortWindowSeconds) * time.Second,
StorageBackendID: normalizeBackendID(settings.UserStorageBackend),
StorageQuotaSet: true,
}
if user.StorageQuotaMB != nil {
policy.StorageQuotaMB = *user.StorageQuotaMB
}
if user.Policy.MaxUploadMB != nil {
policy.MaxUploadMB = *user.Policy.MaxUploadMB
}
if user.Policy.DailyUploadMB != nil {
policy.DailyUploadMB = *user.Policy.DailyUploadMB
}
if user.Policy.StorageQuotaMB != nil {
policy.StorageQuotaMB = *user.Policy.StorageQuotaMB
policy.StorageQuotaSet = *user.Policy.StorageQuotaMB > 0
}
if user.Policy.MaxDays != nil {
policy.MaxDays = *user.Policy.MaxDays
}
if user.Policy.DailyBoxes != nil {
policy.DailyBoxes = *user.Policy.DailyBoxes
}
if user.Policy.ActiveBoxes != nil {
policy.ActiveBoxes = *user.Policy.ActiveBoxes
}
if user.Policy.ShortWindowRequests != nil {
policy.ShortRequests = *user.Policy.ShortWindowRequests
}
if user.Policy.StorageBackendID != nil {
policy.StorageBackendID = normalizeBackendID(*user.Policy.StorageBackendID)
}
return policy
}
func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error { func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error {
if retentionDays <= 0 { if retentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive") return fmt.Errorf("usage retention days must be positive")
@@ -185,6 +384,21 @@ func (s *SettingsService) validate(settings UploadPolicySettings) error {
if settings.UsageRetentionDays <= 0 { if settings.UsageRetentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive") return fmt.Errorf("usage retention days must be positive")
} }
if settings.LocalStorageMaxGB <= 0 {
return fmt.Errorf("local storage max must be positive")
}
if settings.AnonymousMaxDays <= 0 || settings.UserMaxDays <= 0 {
return fmt.Errorf("expiration limits must be positive")
}
if settings.AnonymousDailyBoxes <= 0 || settings.UserDailyBoxes <= 0 {
return fmt.Errorf("daily box limits must be positive")
}
if settings.AnonymousActiveBoxes <= 0 || settings.UserActiveBoxes <= 0 {
return fmt.Errorf("active box limits must be positive")
}
if settings.ShortWindowRequests <= 0 || settings.ShortWindowSeconds <= 0 {
return fmt.Errorf("short-window rate limits must be positive")
}
return nil return nil
} }
@@ -211,6 +425,10 @@ func MegabytesToBytes(value float64) int64 {
return int64(value * 1024 * 1024) return int64(value * 1024 * 1024)
} }
func GigabytesToBytes(value float64) int64 {
return int64(value * 1024 * 1024 * 1024)
}
func FormatMegabytesFromBytes(value int64) string { func FormatMegabytesFromBytes(value int64) string {
mb := float64(value) / 1024 / 1024 mb := float64(value) / 1024 / 1024
return FormatMegabytesLabel(mb) return FormatMegabytesLabel(mb)
@@ -228,6 +446,14 @@ func usageDate(now time.Time) string {
return now.UTC().Format("2006-01-02") return now.UTC().Format("2006-01-02")
} }
func normalizeBackendID(id string) string {
id = strings.TrimSpace(id)
if id == "" {
return StorageBackendLocal
}
return id
}
func ClientIP(remoteAddr, forwardedFor string) string { func ClientIP(remoteAddr, forwardedFor string) string {
if forwardedFor != "" { if forwardedFor != "" {
parts := strings.Split(forwardedFor, ",") parts := strings.Split(forwardedFor, ",")

View File

@@ -146,6 +146,44 @@ func TestDailyUsageAndCleanup(t *testing.T) {
} }
} }
func TestEffectiveUserPolicyUsesOverridesAndInheritance(t *testing.T) {
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
policy.UserDailyUploadMB = 100
policy.DefaultUserStorageMB = 200
policy.UserMaxDays = 30
policy.UserDailyBoxes = 40
policy.UserActiveBoxes = 50
policy.UserStorageBackend = "local"
overrideDaily := 300.0
overrideQuota := 0.0
overrideDays := 12
overrideBackend := "bucket-1"
user := User{
ID: "user-1",
Policy: UserPolicy{
DailyUploadMB: &overrideDaily,
StorageQuotaMB: &overrideQuota,
MaxDays: &overrideDays,
StorageBackendID: &overrideBackend,
},
}
effective := settings.EffectivePolicyForUser(policy, user)
if effective.DailyUploadMB != overrideDaily || effective.MaxDays != overrideDays || effective.StorageBackendID != overrideBackend {
t.Fatalf("effective policy did not use overrides: %+v", effective)
}
if effective.StorageQuotaSet {
t.Fatalf("zero storage quota override should mean unlimited: %+v", effective)
}
if effective.DailyBoxes != policy.UserDailyBoxes || effective.ActiveBoxes != policy.UserActiveBoxes {
t.Fatalf("effective policy did not inherit box caps: %+v", effective)
}
}
func newTestSettingsService(t *testing.T) *SettingsService { func newTestSettingsService(t *testing.T) *SettingsService {
t.Helper() t.Helper()
root := t.TempDir() root := t.TempDir()

View File

@@ -0,0 +1,536 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"go.etcd.io/bbolt"
)
var storageBackendsBucket = []byte("storage_backends")
const (
StorageBackendLocal = "local"
StorageBackendS3 = "s3"
StorageProviderS3 = "s3"
StorageProviderContabo = "contabo"
)
type StorageObject struct {
Key string
Size int64
ContentType string
ModTime time.Time
Body io.ReadCloser
}
type StorageBackend interface {
ID() string
Type() string
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
Get(ctx context.Context, key string) (StorageObject, error)
Delete(ctx context.Context, key string) error
DeletePrefix(ctx context.Context, prefix string) error
Usage(ctx context.Context) (int64, error)
Test(ctx context.Context) error
}
type StorageBackendConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Provider string `json:"provider,omitempty"`
Enabled bool `json:"enabled"`
LocalPath string `json:"localPath,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Region string `json:"region,omitempty"`
Bucket string `json:"bucket,omitempty"`
AccessKey string `json:"accessKey,omitempty"`
SecretKey string `json:"secretKey,omitempty"`
UseSSL bool `json:"useSsl,omitempty"`
PathStyle bool `json:"pathStyle,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastTestedAt time.Time `json:"lastTestedAt,omitempty"`
LastTestError string `json:"lastTestError,omitempty"`
LastTestSuccess bool `json:"lastTestSuccess,omitempty"`
}
type StorageBackendView struct {
Config StorageBackendConfig
UsageBytes int64
UsageLabel string
InUse bool
}
type StorageService struct {
db *bbolt.DB
localFilesDir string
}
func NewStorageService(db *bbolt.DB, dataDir string) (*StorageService, error) {
filesDir := filepath.Join(dataDir, "files")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return nil, err
}
service := &StorageService{db: db, localFilesDir: filesDir}
err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(storageBackendsBucket)
return err
})
if err != nil {
return nil, err
}
return service, nil
}
func (s *StorageService) LocalFilesDir() string {
return s.localFilesDir
}
func (s *StorageService) Backend(id string) (StorageBackend, error) {
cfg, err := s.BackendConfig(id)
if err != nil {
return nil, err
}
if !cfg.Enabled {
return nil, fmt.Errorf("storage backend is disabled")
}
return s.backendFromConfig(cfg)
}
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
id = strings.TrimSpace(id)
if id == "" || id == StorageBackendLocal {
return s.localConfig(), nil
}
var cfg StorageBackendConfig
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(storageBackendsBucket).Get([]byte(id))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &cfg)
})
if err != nil {
return StorageBackendConfig{}, err
}
return cfg, nil
}
func (s *StorageService) ListBackendConfigs() ([]StorageBackendConfig, error) {
configs := []StorageBackendConfig{s.localConfig()}
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(storageBackendsBucket).ForEach(func(_, value []byte) error {
var cfg StorageBackendConfig
if err := json.Unmarshal(value, &cfg); err != nil {
return err
}
configs = append(configs, cfg)
return nil
})
})
sort.Slice(configs, func(i, j int) bool {
if configs[i].ID == StorageBackendLocal {
return true
}
if configs[j].ID == StorageBackendLocal {
return false
}
return strings.ToLower(configs[i].Name) < strings.ToLower(configs[j].Name)
})
return configs, err
}
func (s *StorageService) CreateS3Backend(input StorageBackendConfig) (StorageBackendConfig, error) {
input.ID = randomID(10)
input.Type = StorageBackendS3
input.Provider = normalizeStorageProvider(input.Provider)
if input.Provider == StorageProviderContabo {
input.UseSSL = true
input.PathStyle = true
}
input.Name = strings.TrimSpace(input.Name)
input.Endpoint = strings.TrimSpace(input.Endpoint)
input.Region = strings.TrimSpace(input.Region)
input.Bucket = strings.TrimSpace(input.Bucket)
input.AccessKey = strings.TrimSpace(input.AccessKey)
input.SecretKey = strings.TrimSpace(input.SecretKey)
if input.Name == "" {
input.Name = input.Bucket
}
if input.Name == "" || input.Endpoint == "" || input.Bucket == "" || input.AccessKey == "" || input.SecretKey == "" {
return StorageBackendConfig{}, fmt.Errorf("name, endpoint, bucket, access key, and secret key are required")
}
now := time.Now().UTC()
input.Enabled = true
input.CreatedAt = now
input.UpdatedAt = now
if err := s.SaveBackendConfig(input); err != nil {
return StorageBackendConfig{}, err
}
return input, nil
}
func (s *StorageService) UpdateS3Backend(id string, input StorageBackendConfig) (StorageBackendConfig, error) {
current, err := s.BackendConfig(id)
if err != nil {
return StorageBackendConfig{}, err
}
if current.ID == StorageBackendLocal || current.Type != StorageBackendS3 {
return StorageBackendConfig{}, fmt.Errorf("only S3-compatible storage can be edited")
}
current.Provider = normalizeStorageProvider(input.Provider)
if current.Provider == StorageProviderContabo {
input.UseSSL = true
input.PathStyle = true
}
current.Name = strings.TrimSpace(input.Name)
current.Endpoint = strings.TrimSpace(input.Endpoint)
current.Region = strings.TrimSpace(input.Region)
current.Bucket = strings.TrimSpace(input.Bucket)
current.AccessKey = strings.TrimSpace(input.AccessKey)
if strings.TrimSpace(input.SecretKey) != "" {
current.SecretKey = strings.TrimSpace(input.SecretKey)
}
current.UseSSL = input.UseSSL
current.PathStyle = input.PathStyle
if current.Name == "" {
current.Name = current.Bucket
}
if current.Name == "" || current.Endpoint == "" || current.Bucket == "" || current.AccessKey == "" || current.SecretKey == "" {
return StorageBackendConfig{}, fmt.Errorf("name, endpoint, bucket, access key, and secret key are required")
}
if err := s.SaveBackendConfig(current); err != nil {
return StorageBackendConfig{}, err
}
return current, nil
}
func (s *StorageService) SaveBackendConfig(cfg StorageBackendConfig) error {
if cfg.ID == "" || cfg.ID == StorageBackendLocal {
return fmt.Errorf("invalid storage backend id")
}
cfg.UpdatedAt = time.Now().UTC()
data, err := json.Marshal(cfg)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(storageBackendsBucket).Put([]byte(cfg.ID), data)
})
}
func (s *StorageService) DisableBackend(id string, inUse bool) error {
if id == "" || id == StorageBackendLocal {
return fmt.Errorf("local storage cannot be disabled")
}
if inUse {
return fmt.Errorf("storage backend is in use")
}
cfg, err := s.BackendConfig(id)
if err != nil {
return err
}
cfg.Enabled = false
return s.SaveBackendConfig(cfg)
}
func (s *StorageService) DeleteBackend(id string, inUse bool) error {
if id == "" || id == StorageBackendLocal {
return fmt.Errorf("local storage cannot be deleted")
}
if inUse {
return fmt.Errorf("storage backend is in use")
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(storageBackendsBucket).Delete([]byte(id))
})
}
func (s *StorageService) TestBackend(id string) (StorageBackendConfig, error) {
cfg, err := s.BackendConfig(id)
if err != nil {
return StorageBackendConfig{}, err
}
backend, err := s.backendFromConfig(cfg)
if err != nil {
return StorageBackendConfig{}, err
}
err = backend.Test(context.Background())
cfg.LastTestedAt = time.Now().UTC()
cfg.LastTestError = ""
cfg.LastTestSuccess = err == nil
if err != nil {
cfg.LastTestError = err.Error()
}
if cfg.ID != StorageBackendLocal {
_ = s.SaveBackendConfig(cfg)
}
return cfg, err
}
func (s *StorageService) backendFromConfig(cfg StorageBackendConfig) (StorageBackend, error) {
switch cfg.Type {
case StorageBackendLocal:
return localStorageBackend{id: cfg.ID, root: cfg.LocalPath}, nil
case StorageBackendS3:
return newS3StorageBackend(cfg)
default:
return nil, fmt.Errorf("unsupported storage backend type %q", cfg.Type)
}
}
func (s *StorageService) localConfig() StorageBackendConfig {
now := time.Now().UTC()
return StorageBackendConfig{
ID: StorageBackendLocal,
Name: "Local files",
Type: StorageBackendLocal,
Provider: StorageBackendLocal,
Enabled: true,
LocalPath: s.localFilesDir,
CreatedAt: now,
UpdatedAt: now,
}
}
type localStorageBackend struct {
id string
root string
}
func (b localStorageBackend) ID() string { return b.id }
func (b localStorageBackend) Type() string { return StorageBackendLocal }
func (b localStorageBackend) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
path, err := b.path(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
target, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer target.Close()
_, err = io.Copy(target, body)
return err
}
func (b localStorageBackend) Get(_ context.Context, key string) (StorageObject, error) {
path, err := b.path(key)
if err != nil {
return StorageObject{}, err
}
source, err := os.Open(path)
if err != nil {
return StorageObject{}, err
}
stat, err := source.Stat()
if err != nil {
source.Close()
return StorageObject{}, err
}
return StorageObject{Key: key, Size: stat.Size(), ModTime: stat.ModTime(), Body: source}, nil
}
func (b localStorageBackend) Delete(_ context.Context, key string) error {
path, err := b.path(key)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (b localStorageBackend) DeletePrefix(_ context.Context, prefix string) error {
path, err := b.path(prefix)
if err != nil {
return err
}
if err := os.RemoveAll(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (b localStorageBackend) Usage(_ context.Context) (int64, error) {
var total int64
err := filepath.WalkDir(b.root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
if os.IsNotExist(err) {
return 0, nil
}
return total, err
}
func (b localStorageBackend) Test(ctx context.Context) error {
key := ".warpbox-storage-test-" + randomID(6)
if err := b.Put(ctx, key, strings.NewReader("ok"), 2, "text/plain"); err != nil {
return err
}
return b.Delete(ctx, key)
}
func (b localStorageBackend) path(key string) (string, error) {
key = filepath.Clean(strings.TrimPrefix(key, "/"))
if key == "." || strings.HasPrefix(key, "..") || filepath.IsAbs(key) {
return "", fmt.Errorf("invalid storage key")
}
path := filepath.Join(b.root, key)
root, err := filepath.Abs(b.root)
if err != nil {
return "", err
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
if abs != root && !strings.HasPrefix(abs, root+string(os.PathSeparator)) {
return "", fmt.Errorf("invalid storage key")
}
return abs, nil
}
type s3StorageBackend struct {
cfg StorageBackendConfig
client *minio.Client
}
func newS3StorageBackend(cfg StorageBackendConfig) (*s3StorageBackend, error) {
endpoint := normalizeS3Endpoint(cfg.Endpoint)
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
BucketLookup: s3BucketLookup(cfg.PathStyle),
})
if err != nil {
return nil, err
}
return &s3StorageBackend{cfg: cfg, client: client}, nil
}
func (b *s3StorageBackend) ID() string { return b.cfg.ID }
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
opts := minio.PutObjectOptions{ContentType: contentType}
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
return err
}
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
if err != nil {
return StorageObject{}, err
}
info, err := object.Stat()
if err != nil {
object.Close()
return StorageObject{}, err
}
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
}
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
}
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
prefix = strings.TrimSuffix(cleanObjectKey(prefix), "/") + "/"
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
for object := range objects {
if object.Err != nil {
return object.Err
}
if err := b.Delete(ctx, object.Key); err != nil {
return err
}
}
return nil
}
func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
var total int64
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
if object.Err != nil {
return 0, object.Err
}
total += object.Size
}
return total, nil
}
func (b *s3StorageBackend) Test(ctx context.Context) error {
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
}
key := ".warpbox-storage-test-" + randomID(6)
if err := b.Put(ctx, key, bytes.NewReader([]byte("ok")), 2, "text/plain"); err != nil {
return err
}
return b.Delete(ctx, key)
}
func s3BucketLookup(pathStyle bool) minio.BucketLookupType {
if pathStyle {
return minio.BucketLookupPath
}
return minio.BucketLookupAuto
}
func normalizeS3Endpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
if parsed, err := url.Parse(endpoint); err == nil && parsed.Host != "" {
return parsed.Host
}
return strings.TrimPrefix(strings.TrimPrefix(endpoint, "https://"), "http://")
}
func normalizeStorageProvider(provider string) string {
switch strings.TrimSpace(provider) {
case StorageProviderContabo:
return StorageProviderContabo
default:
return StorageProviderS3
}
}
func cleanObjectKey(key string) string {
return strings.TrimPrefix(filepath.ToSlash(filepath.Clean(strings.TrimPrefix(key, "/"))), "./")
}

View File

@@ -2,6 +2,8 @@ package services
import ( import (
"archive/zip" "archive/zip"
"bytes"
"context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
@@ -12,6 +14,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -31,6 +34,7 @@ type UploadService struct {
filesDir string filesDir string
db *bbolt.DB db *bbolt.DB
logger *slog.Logger logger *slog.Logger
storage *StorageService
} }
type UploadOptions struct { type UploadOptions struct {
@@ -41,33 +45,39 @@ type UploadOptions struct {
OwnerID string OwnerID string
CollectionID string CollectionID string
SkipSizeLimit bool SkipSizeLimit bool
CreatorIP string
StorageBackendID string
} }
type Box struct { type Box struct {
ID string `json:"id"` ID string `json:"id"`
OwnerID string `json:"ownerId,omitempty"` OwnerID string `json:"ownerId,omitempty"`
CollectionID string `json:"collectionId,omitempty"` CollectionID string `json:"collectionId,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time `json:"expiresAt"`
MaxDownloads int `json:"maxDownloads"` MaxDownloads int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"` DownloadCount int `json:"downloadCount"`
PasswordSalt string `json:"passwordSalt,omitempty"` PasswordSalt string `json:"passwordSalt,omitempty"`
PasswordHash string `json:"passwordHash,omitempty"` PasswordHash string `json:"passwordHash,omitempty"`
DeleteTokenHash string `json:"deleteTokenHash,omitempty"` DeleteTokenHash string `json:"deleteTokenHash,omitempty"`
Obfuscate bool `json:"obfuscate"` Obfuscate bool `json:"obfuscate"`
Files []File `json:"files"` CreatorIP string `json:"creatorIp,omitempty"`
StorageBackendID string `json:"storageBackendId,omitempty"`
Files []File `json:"files"`
} }
type File struct { type File struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
StoredName string `json:"storedName"` StoredName string `json:"storedName"`
Size int64 `json:"size"` Size int64 `json:"size"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"` PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
UploadedAt time.Time `json:"uploadedAt"` ObjectKey string `json:"objectKey,omitempty"`
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
UploadedAt time.Time `json:"uploadedAt"`
} }
type UploadResult struct { type UploadResult struct {
@@ -121,9 +131,6 @@ type UserBox struct {
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) { func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
filesDir := filepath.Join(dataDir, "files") filesDir := filepath.Join(dataDir, "files")
dbDir := filepath.Join(dataDir, "db") dbDir := filepath.Join(dataDir, "db")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return nil, err
}
if err := os.MkdirAll(dbDir, 0o755); err != nil { if err := os.MkdirAll(dbDir, 0o755); err != nil {
return nil, err return nil, err
} }
@@ -140,6 +147,11 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
db.Close() db.Close()
return nil, err return nil, err
} }
storage, err := NewStorageService(db, dataDir)
if err != nil {
db.Close()
return nil, err
}
return &UploadService{ return &UploadService{
maxUploadSize: maxUploadSize, maxUploadSize: maxUploadSize,
@@ -148,6 +160,7 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
filesDir: filesDir, filesDir: filesDir,
db: db, db: db,
logger: logger, logger: logger,
storage: storage,
}, nil }, nil
} }
@@ -167,6 +180,10 @@ func (s *UploadService) MaxUploadSizeLabel() string {
return helpers.FormatBytes(s.maxUploadSize) return helpers.FormatBytes(s.maxUploadSize)
} }
func (s *UploadService) Storage() *StorageService {
return s.storage
}
func (s *UploadService) ValidateSize(size int64) error { func (s *UploadService) ValidateSize(size int64) error {
if size > s.maxUploadSize { if size > s.maxUploadSize {
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel()) return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
@@ -183,14 +200,16 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
} }
box := Box{ box := Box{
ID: randomID(10), ID: randomID(10),
OwnerID: strings.TrimSpace(opts.OwnerID), OwnerID: strings.TrimSpace(opts.OwnerID),
CollectionID: strings.TrimSpace(opts.CollectionID), CollectionID: strings.TrimSpace(opts.CollectionID),
CreatedAt: time.Now().UTC(), CreatorIP: strings.TrimSpace(opts.CreatorIP),
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour), StorageBackendID: normalizeBackendID(opts.StorageBackendID),
MaxDownloads: opts.MaxDownloads, CreatedAt: time.Now().UTC(),
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "", ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
Files: make([]File, 0, len(files)), MaxDownloads: opts.MaxDownloads,
Obfuscate: opts.ObfuscateMetadata && strings.TrimSpace(opts.Password) != "",
Files: make([]File, 0, len(files)),
} }
deleteToken := randomID(32) deleteToken := randomID(32)
box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken) box.DeleteTokenHash = deleteTokenHash(box.ID, deleteToken)
@@ -200,8 +219,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
box.PasswordHash = hash box.PasswordHash = hash
} }
boxDir := filepath.Join(s.filesDir, box.ID) backend, err := s.storage.Backend(box.StorageBackendID)
if err := os.MkdirAll(boxDir, 0o755); err != nil { if err != nil {
return UploadResult{}, err return UploadResult{}, err
} }
@@ -224,13 +243,18 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
fileID := randomID(8) fileID := randomID(8)
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename)) storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(header.Filename))
storedPath := filepath.Join(boxDir, storedName) objectKey := boxObjectKey(box.ID, storedName)
contentType := header.Header.Get("Content-Type") contentType := header.Header.Get("Content-Type")
if contentType == "" { if contentType == "" {
contentType = "application/octet-stream" buffer := make([]byte, 512)
n, _ := file.Read(buffer)
contentType = http.DetectContentType(buffer[:n])
if seeker, ok := file.(io.Seeker); ok {
_, _ = seeker.Seek(0, io.SeekStart)
}
} }
if err := writeUploadedFile(storedPath, file, maxSize); err != nil { if err := s.writeUploadedObject(context.Background(), backend, objectKey, file, header.Size, maxSize, contentType); err != nil {
file.Close() file.Close()
return UploadResult{}, err return UploadResult{}, err
} }
@@ -243,6 +267,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
Size: header.Size, Size: header.Size,
ContentType: contentType, ContentType: contentType,
PreviewKind: previewKind(contentType), PreviewKind: previewKind(contentType),
ObjectKey: objectKey,
UploadedAt: time.Now().UTC(), UploadedAt: time.Now().UTC(),
}) })
} }
@@ -296,6 +321,29 @@ func (s *UploadService) ListBoxes(limit int) ([]Box, error) {
return boxes, err return boxes, err
} }
func (s *UploadService) ActiveBoxCountForUser(userID string) (int, error) {
return s.activeBoxCount(func(box Box) bool { return box.OwnerID == userID })
}
func (s *UploadService) ActiveBoxCountForIP(ip string) (int, error) {
return s.activeBoxCount(func(box Box) bool { return box.OwnerID == "" && box.CreatorIP == ip })
}
func (s *UploadService) activeBoxCount(match func(Box) bool) (int, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return 0, err
}
now := time.Now().UTC()
count := 0
for _, box := range boxes {
if match(box) && box.ExpiresAt.After(now) {
count++
}
}
return count, nil
}
func (s *UploadService) AdminStats() (AdminStats, error) { func (s *UploadService) AdminStats() (AdminStats, error) {
boxes, err := s.ListBoxes(0) boxes, err := s.ListBoxes(0)
if err != nil { if err != nil {
@@ -463,13 +511,22 @@ func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
} }
func (s *UploadService) DeleteBoxWithSource(boxID, source string) error { func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
box, _ := s.GetBox(boxID)
if err := s.db.Update(func(tx *bbolt.Tx) error { if err := s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(boxesBucket).Delete([]byte(boxID)) return tx.Bucket(boxesBucket).Delete([]byte(boxID))
}); err != nil { }); err != nil {
return err return err
} }
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil { if box.ID != "" {
return err if backend, err := s.storage.Backend(s.BoxStorageBackendID(box)); err == nil {
if err := backend.DeletePrefix(context.Background(), box.ID); err != nil {
return err
}
}
} else {
if err := os.RemoveAll(filepath.Join(s.filesDir, boxID)); err != nil {
return err
}
} }
s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID) s.logger.Info("box deleted", "source", source, "severity", "user_activity", "code", 2101, "box_id", boxID)
return nil return nil
@@ -499,6 +556,56 @@ func (s *UploadService) BoxMetadataPath(box Box) string {
return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json") return filepath.Join(s.filesDir, box.ID, ".warpbox.box.json")
} }
func (s *UploadService) BoxStorageBackendID(box Box) string {
return normalizeBackendID(box.StorageBackendID)
}
func (s *UploadService) FileObjectKey(box Box, file File) string {
if file.ObjectKey != "" {
return file.ObjectKey
}
return boxObjectKey(box.ID, file.StoredName)
}
func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
if file.ThumbnailObjectKey != "" {
return file.ThumbnailObjectKey
}
if file.Thumbnail == "" {
return ""
}
return boxObjectKey(box.ID, file.Thumbnail)
}
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return StorageObject{}, err
}
return backend.Get(ctx, s.FileObjectKey(box, file))
}
func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
key := s.ThumbnailObjectKey(box, file)
if key == "" {
return StorageObject{}, os.ErrNotExist
}
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return StorageObject{}, err
}
return backend.Get(ctx, key)
}
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return "", err
}
key := boxObjectKey(box.ID, name)
return key, backend.Put(ctx, key, body, size, contentType)
}
func (s *UploadService) IsProtected(box Box) bool { func (s *UploadService) IsProtected(box Box) bool {
return box.PasswordHash != "" && box.PasswordSalt != "" return box.PasswordHash != "" && box.PasswordSalt != ""
} }
@@ -564,11 +671,11 @@ func (s *UploadService) WriteZip(w io.Writer, box Box) error {
defer archive.Close() defer archive.Close()
for _, file := range box.Files { for _, file := range box.Files {
path := s.FilePath(box, file) object, err := s.OpenFileObject(context.Background(), box, file)
source, err := os.Open(path)
if err != nil { if err != nil {
return err return err
} }
source := object.Body
header := &zip.FileHeader{ header := &zip.FileHeader{
Name: file.Name, Name: file.Name,
@@ -592,6 +699,9 @@ func (s *UploadService) WriteZip(w io.Writer, box Box) error {
} }
func (s *UploadService) SaveBox(box Box) error { func (s *UploadService) SaveBox(box Box) error {
if box.StorageBackendID == "" {
box.StorageBackendID = StorageBackendLocal
}
data, err := json.Marshal(box) data, err := json.Marshal(box)
if err != nil { if err != nil {
return err return err
@@ -654,6 +764,27 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
return nil return nil
} }
func (s *UploadService) writeUploadedObject(ctx context.Context, backend StorageBackend, key string, source multipart.File, size, maxSize int64, contentType string) error {
var reader io.Reader = source
if maxSize > 0 {
reader = io.LimitReader(source, maxSize+1)
var buffer bytes.Buffer
written, err := io.Copy(&buffer, reader)
if err != nil {
return err
}
if written > maxSize {
return fmt.Errorf("file exceeds max upload size")
}
return backend.Put(ctx, key, bytes.NewReader(buffer.Bytes()), written, contentType)
}
return backend.Put(ctx, key, reader, size, contentType)
}
func boxObjectKey(boxID, name string) string {
return filepath.ToSlash(filepath.Join(boxID, name))
}
func randomID(byteCount int) string { func randomID(byteCount int) string {
data := make([]byte, byteCount) data := make([]byte, byteCount)
if _, err := rand.Read(data); err != nil { if _, err := rand.Read(data); err != nil {
@@ -691,10 +822,13 @@ func previewKind(contentType string) string {
} }
func (s *UploadService) writeBoxMetadata(box Box) error { func (s *UploadService) writeBoxMetadata(box Box) error {
path := s.BoxMetadataPath(box)
data, err := json.MarshalIndent(box, "", " ") data, err := json.MarshalIndent(box, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(path, data, 0o600) backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil {
return err
}
return backend.Put(context.Background(), boxObjectKey(box.ID, ".warpbox.box.json"), bytes.NewReader(data), int64(len(data)), "application/json")
} }

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"bytes" "bytes"
"context"
"io" "io"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
@@ -93,6 +94,64 @@ func TestUserActiveStorageUsedIgnoresExpiredBoxes(t *testing.T) {
} }
} }
func TestLocalStorageBackendAndLegacyFallback(t *testing.T) {
service := newTestUploadService(t)
result := createTestBox(t, service, "file.txt", "hello")
box := getTestBox(t, service, result.BoxID)
if service.BoxStorageBackendID(box) != StorageBackendLocal {
t.Fatalf("BoxStorageBackendID = %q", service.BoxStorageBackendID(box))
}
if box.Files[0].ObjectKey == "" {
t.Fatalf("new file did not store object key")
}
object, err := service.OpenFileObject(testContext(), box, box.Files[0])
if err != nil {
t.Fatalf("OpenFileObject returned error: %v", err)
}
data, err := io.ReadAll(object.Body)
object.Body.Close()
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if string(data) != "hello" {
t.Fatalf("object body = %q", string(data))
}
box.StorageBackendID = ""
box.Files[0].ObjectKey = ""
object, err = service.OpenFileObject(testContext(), box, box.Files[0])
if err != nil {
t.Fatalf("legacy OpenFileObject returned error: %v", err)
}
object.Body.Close()
}
func TestContaboStorageConfigAllowsDisplayNamesWithSpaces(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderContabo,
Name: "Contabo main",
Endpoint: "https://eu2.contabostorage.com",
Region: "EU",
Bucket: "My Main Bucket",
AccessKey: "access",
SecretKey: "secret",
})
if err != nil {
t.Fatalf("CreateS3Backend returned error: %v", err)
}
if cfg.Provider != StorageProviderContabo || !cfg.UseSSL || !cfg.PathStyle {
t.Fatalf("contabo config was not normalized: %+v", cfg)
}
if cfg.Bucket != "My Main Bucket" {
t.Fatalf("bucket = %q", cfg.Bucket)
}
}
func testContext() context.Context {
return context.Background()
}
func newTestUploadService(t *testing.T) *UploadService { func newTestUploadService(t *testing.T) *UploadService {
t.Helper() t.Helper()
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil))) service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))

View File

@@ -21,6 +21,7 @@ type PageData struct {
ImageURL string ImageURL string
CurrentYear int CurrentYear int
CurrentUser any CurrentUser any
CSRFToken string
Data any Data any
} }

View File

@@ -1414,6 +1414,45 @@ pre code {
padding: 0.25rem 0.55rem; padding: 0.25rem 0.55rem;
} }
.storage-edit-form {
position: absolute;
right: 1.5rem;
z-index: 30;
width: min(26rem, calc(100vw - 2rem));
display: grid;
grid-template-columns: 1fr 1fr;
align-items: end;
gap: 0.6rem;
padding: 0.85rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
box-shadow: var(--shadow);
}
.storage-edit-form label {
display: grid;
gap: 0.25rem;
}
.storage-edit-form label span {
color: var(--muted-foreground);
font-size: 0.72rem;
}
.storage-edit-form .checkbox-field,
.storage-edit-form button {
align-self: center;
}
@media (max-width: 720px) {
.storage-edit-form {
position: static;
grid-template-columns: 1fr;
width: 100%;
}
}
/* Badge variants */ /* Badge variants */
.badge-active { .badge-active {
background: rgba(134, 239, 172, 0.12); background: rgba(134, 239, 172, 0.12);

View File

@@ -18,6 +18,7 @@
const previewImages = document.querySelector("[data-preview-images]"); const previewImages = document.querySelector("[data-preview-images]");
const previewActions = document.querySelectorAll("[data-preview-action]"); const previewActions = document.querySelectorAll("[data-preview-action]");
const fileContextMenu = document.querySelector("[data-file-context-menu]"); const fileContextMenu = document.querySelector("[data-file-context-menu]");
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
let ctrlCopyMode = false; let ctrlCopyMode = false;
let contextFile = null; let contextFile = null;
const contextMenuCloseDistance = 80; const contextMenuCloseDistance = 80;
@@ -121,6 +122,30 @@
}); });
} }
if (storageProviderSelects.length > 0) {
storageProviderSelects.forEach((select) => {
const formScope = select.closest("form");
const syncStorageProvider = () => {
if (!formScope) {
return;
}
const isContabo = select.value === "contabo";
const tls = formScope.querySelector('input[name="use_ssl"]');
const pathStyle = formScope.querySelector('input[name="path_style"]');
if (tls) {
tls.checked = isContabo || tls.checked;
tls.disabled = isContabo;
}
if (pathStyle) {
pathStyle.checked = isContabo || pathStyle.checked;
pathStyle.disabled = isContabo;
}
};
select.addEventListener("change", syncStorageProvider);
syncStorageProvider();
});
}
if (!form || !dropZone || !fileInput) { if (!form || !dropZone || !fileInput) {
return; return;
} }

View File

@@ -10,6 +10,7 @@
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<form class="sidebar-logout" action="/logout" method="post"> <form class="sidebar-logout" action="/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Sign out</button> <button class="button button-outline" type="submit">Sign out</button>
</form> </form>
</aside> </aside>
@@ -33,6 +34,7 @@
</div> </div>
</div> </div>
<form class="settings-form settings-form-narrow" action="/account/password" method="post"> <form class="settings-form settings-form-narrow" action="/account/password" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label><span>Current password</span><input type="password" name="current_password" autocomplete="current-password" required></label> <label><span>Current password</span><input type="password" name="current_password" autocomplete="current-password" required></label>
<label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label> <label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Update password</button> <button class="button button-primary" type="submit">Update password</button>

View File

@@ -8,6 +8,7 @@
<a class="sidebar-link {{if eq .Data.Section "files"}}is-active{{end}}" href="/admin/files">Files</a> <a class="sidebar-link {{if eq .Data.Section "files"}}is-active{{end}}" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a> <a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a> <a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/admin/storage">Storage</a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -15,6 +16,7 @@
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post"> <form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Sign out</button> <button class="button button-outline" type="submit">Sign out</button>
</form> </form>
</aside> </aside>
@@ -96,6 +98,7 @@
<td class="table-actions"> <td class="table-actions">
<a class="button button-outline" href="/admin/boxes/{{.ID}}/view">View</a> <a class="button button-outline" href="/admin/boxes/{{.ID}}/view">View</a>
<form action="/admin/boxes/{{.ID}}/delete" method="post"> <form action="/admin/boxes/{{.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger" type="submit">Delete</button> <button class="button button-danger" type="submit">Delete</button>
</form> </form>
</td> </td>

View File

@@ -8,6 +8,7 @@
<a class="sidebar-link" href="/admin/files">Files</a> <a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a> <a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link is-active" href="/admin/settings">Settings</a> <a class="sidebar-link is-active" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/admin/storage">Storage</a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -15,6 +16,7 @@
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post"> <form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Sign out</button> <button class="button button-outline" type="submit">Sign out</button>
</form> </form>
</aside> </aside>
@@ -37,6 +39,7 @@
</div> </div>
<form class="settings-form" action="/admin/settings" method="post"> <form class="settings-form" action="/admin/settings" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="settings-section"> <div class="settings-section">
<h3 class="settings-section-title">Anonymous uploads</h3> <h3 class="settings-section-title">Anonymous uploads</h3>
<label class="checkbox-field"> <label class="checkbox-field">
@@ -51,6 +54,26 @@
<span>Daily cap per IP (MB)</span> <span>Daily cap per IP (MB)</span>
<input name="anonymous_daily_upload_mb" value="{{.Data.Settings.AnonymousDailyUploadMB}}" required> <input name="anonymous_daily_upload_mb" value="{{.Data.Settings.AnonymousDailyUploadMB}}" required>
</label> </label>
<label>
<span>Daily boxes per IP</span>
<input type="number" name="anonymous_daily_boxes" min="1" value="{{.Data.Settings.AnonymousDailyBoxes}}" required>
</label>
<label>
<span>Active boxes per IP</span>
<input type="number" name="anonymous_active_boxes" min="1" value="{{.Data.Settings.AnonymousActiveBoxes}}" required>
</label>
<label>
<span>Max expiration (days)</span>
<input type="number" name="anonymous_max_days" min="1" value="{{.Data.Settings.AnonymousMaxDays}}" required>
</label>
<label>
<span>Anonymous storage backend</span>
<select name="anonymous_storage_backend" required>
{{range .Data.Storage}}
{{if or .Config.Enabled (eq $.Data.Settings.AnonymousStorageBackend .Config.ID)}}<option value="{{.Config.ID}}" {{if eq $.Data.Settings.AnonymousStorageBackend .Config.ID}}selected{{end}}>{{.Config.Name}} ({{.Config.ID}})</option>{{end}}
{{end}}
</select>
</label>
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -67,6 +90,38 @@
<span>Usage retention (days)</span> <span>Usage retention (days)</span>
<input type="number" name="usage_retention_days" min="1" value="{{.Data.Settings.UsageRetentionDays}}" required> <input type="number" name="usage_retention_days" min="1" value="{{.Data.Settings.UsageRetentionDays}}" required>
</label> </label>
<label>
<span>Daily boxes</span>
<input type="number" name="user_daily_boxes" min="1" value="{{.Data.Settings.UserDailyBoxes}}" required>
</label>
<label>
<span>Active boxes</span>
<input type="number" name="user_active_boxes" min="1" value="{{.Data.Settings.UserActiveBoxes}}" required>
</label>
<label>
<span>Max expiration (days)</span>
<input type="number" name="user_max_days" min="1" value="{{.Data.Settings.UserMaxDays}}" required>
</label>
<label>
<span>User storage backend</span>
<select name="user_storage_backend" required>
{{range .Data.Storage}}
{{if or .Config.Enabled (eq $.Data.Settings.UserStorageBackend .Config.ID)}}<option value="{{.Config.ID}}" {{if eq $.Data.Settings.UserStorageBackend .Config.ID}}selected{{end}}>{{.Config.Name}} ({{.Config.ID}})</option>{{end}}
{{end}}
</select>
</label>
<label>
<span>Local storage max (GB)</span>
<input name="local_storage_max_gb" value="{{.Data.Settings.LocalStorageMaxGB}}" required>
</label>
<label>
<span>Short-window requests</span>
<input type="number" name="short_window_requests" min="1" value="{{.Data.Settings.ShortWindowRequests}}" required>
</label>
<label>
<span>Short-window seconds</span>
<input type="number" name="short_window_seconds" min="1" value="{{.Data.Settings.ShortWindowSeconds}}" required>
</label>
</div> </div>
<button class="button button-primary" type="submit">Save settings</button> <button class="button button-primary" type="submit">Save settings</button>

View File

@@ -0,0 +1,124 @@
{{define "admin_storage.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-storage-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">Overview</a>
<a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link is-active" href="/admin/storage">Storage</a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav"><a class="sidebar-link" href="/app">My Files</a></nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-storage-title">{{.Data.PageTitle}}</h1>
</div>
</div>
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Storage backends</h2>
<p>Local storage is always available. S3-compatible backends stay private behind Warpbox routes.</p>
</div>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Usage</th><th>Details</th><th>Actions</th></tr></thead>
<tbody>
{{range .Data.Storage}}
<tr>
<td>{{.Config.Name}}</td>
<td>{{if eq .Config.Provider "contabo"}}Contabo Object Storage{{else if eq .Config.Type "s3"}}S3 bucket{{else}}{{.Config.Type}}{{end}}</td>
<td>{{if .Config.Enabled}}Enabled{{else}}Disabled{{end}}</td>
<td>{{.UsageLabel}}</td>
<td>{{if eq .Config.Type "local"}}{{.Config.LocalPath}}{{else}}{{.Config.Bucket}} @ {{.Config.Endpoint}}{{end}}</td>
<td class="table-actions">
<form action="/admin/storage/{{.Config.ID}}/test" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-outline button-sm" type="submit">Test</button>
</form>
{{if ne .Config.ID "local"}}
<details class="row-edit">
<summary class="button button-outline button-sm">Edit</summary>
<form action="/admin/storage/{{.Config.ID}}/edit" method="post" class="row-edit-form storage-edit-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<label><span>Storage kind</span><select name="provider" data-storage-provider>
<option value="s3" {{if ne .Config.Provider "contabo"}}selected{{end}}>S3 bucket</option>
<option value="contabo" {{if eq .Config.Provider "contabo"}}selected{{end}}>Contabo Object Storage</option>
</select></label>
<label><span>Name</span><input name="name" value="{{.Config.Name}}" required></label>
<label><span>Endpoint</span><input name="endpoint" value="{{.Config.Endpoint}}" required></label>
<label><span>Region</span><input name="region" value="{{.Config.Region}}"></label>
<label><span>Bucket / object storage name</span><input name="bucket" value="{{.Config.Bucket}}" required></label>
<label><span>Access key</span><input name="access_key" value="{{.Config.AccessKey}}" required></label>
<label><span>Secret key</span><input name="secret_key" type="password" placeholder="Leave unchanged"></label>
<label class="checkbox-field"><input type="checkbox" name="use_ssl" {{if .Config.UseSSL}}checked{{end}}><span>Use TLS</span></label>
<label class="checkbox-field"><input type="checkbox" name="path_style" {{if .Config.PathStyle}}checked{{end}}><span>Path-style lookup</span></label>
<button class="button button-primary button-sm" type="submit">Save</button>
</form>
</details>
<form action="/admin/storage/{{.Config.ID}}/disable" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit" {{if .InUse}}disabled{{end}}>Disable</button>
</form>
<form action="/admin/storage/{{.Config.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit" {{if .InUse}}disabled{{end}}>Delete</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Add storage</h2>
<p>Choose a provider kind first. Contabo uses S3-compatible access with path-style requests.</p>
</div>
</div>
<form class="settings-form" action="/admin/storage/s3" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="settings-section">
<label><span>Storage kind</span><select name="provider" data-storage-provider>
<option value="s3">S3 bucket</option>
<option value="contabo">Contabo Object Storage</option>
</select></label>
<label><span>Name</span><input name="name" placeholder="Bob's bucket" required></label>
<label><span>Endpoint</span><input name="endpoint" placeholder="s3.example.com" required></label>
<label><span>Region</span><input name="region" placeholder="us-east-1"></label>
<label><span>Bucket / object storage name</span><input name="bucket" placeholder="My Main Bucket" required></label>
<label><span>Access key</span><input name="access_key" required></label>
<label><span>Secret key</span><input name="secret_key" type="password" required></label>
<label class="checkbox-field"><input type="checkbox" name="use_ssl" checked><span>Use TLS</span></label>
<label class="checkbox-field"><input type="checkbox" name="path_style"><span>Use path-style bucket lookup</span></label>
</div>
<button class="button button-primary" type="submit">Add bucket</button>
</form>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,117 @@
{{define "admin_user_edit.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-user-edit-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/admin">Overview</a>
<a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link is-active" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/admin/storage">Storage</a>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav"><a class="sidebar-link" href="/app">My Files</a></nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-user-edit-title">{{.Data.PageTitle}}</h1>
<p class="muted-copy">{{.Data.UserEdit.Email}} · {{.Data.UserEdit.Role}}</p>
</div>
<a class="button button-outline" href="/admin/users">Back to users</a>
</div>
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
{{if .Data.LastInviteURL}}
<div class="copy-field">
<input type="text" value="{{.Data.LastInviteURL}}" readonly id="reset-url-field" aria-label="Reset link">
<button class="button button-outline button-sm" type="button"
onclick="navigator.clipboard.writeText(document.getElementById('reset-url-field').value).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)})">Copy reset link</button>
</div>
{{end}}
<div class="metric-grid">
<article class="metric-card"><span>Storage used</span><strong>{{.Data.UserEdit.StorageUsed}}</strong></article>
<article class="metric-card"><span>Uploaded today</span><strong>{{.Data.UserEdit.DailyUsed}}</strong></article>
<article class="metric-card"><span>Effective quota</span><strong>{{.Data.UserEdit.EffectiveStorage}}</strong></article>
<article class="metric-card"><span>Effective backend</span><strong>{{.Data.UserEdit.EffectiveBackend}}</strong></article>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Identity and limits</h2>
<p>Blank limit fields inherit the global user defaults. Storage quota set to 0 means unlimited.</p>
</div>
</div>
<form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div class="settings-section">
<h3 class="settings-section-title">Account</h3>
<label><span>Username</span><input name="username" value="{{.Data.UserEdit.Username}}" required></label>
<label><span>Email</span><input type="email" name="email" value="{{.Data.UserEdit.Email}}" required></label>
<label><span>Role</span><select name="role">
<option value="user" {{if eq .Data.UserEdit.Role "user"}}selected{{end}}>User</option>
<option value="admin" {{if eq .Data.UserEdit.Role "admin"}}selected{{end}}>Admin</option>
</select></label>
<label><span>Status</span><select name="status">
<option value="active" {{if eq .Data.UserEdit.Status "active"}}selected{{end}}>Active</option>
<option value="disabled" {{if eq .Data.UserEdit.Status "disabled"}}selected{{end}}>Disabled</option>
</select></label>
</div>
<div class="settings-section">
<h3 class="settings-section-title">Storage</h3>
<label>
<span>Storage backend</span>
<select name="storage_backend_id">
<option value="">Inherit global user backend ({{.Data.UserEdit.EffectiveBackend}})</option>
{{range .Data.Storage}}
{{if or .Config.Enabled (eq $.Data.UserEdit.StorageBackendID .Config.ID)}}<option value="{{.Config.ID}}" {{if eq $.Data.UserEdit.StorageBackendID .Config.ID}}selected{{end}}>{{.Config.Name}} ({{.Config.ID}})</option>{{end}}
{{end}}
</select>
</label>
<label><span>Storage quota override (MB)</span><input name="storage_quota_mb" value="{{.Data.UserEdit.StorageQuotaMB}}" placeholder="inherit"></label>
</div>
<div class="settings-section">
<h3 class="settings-section-title">Upload limits</h3>
<label><span>Max upload size (MB)</span><input name="max_upload_mb" value="{{.Data.UserEdit.MaxUploadMB}}" placeholder="inherit"></label>
<label><span>Daily upload cap (MB)</span><input name="daily_upload_mb" value="{{.Data.UserEdit.DailyUploadMB}}" placeholder="inherit"></label>
<label><span>Max expiration (days)</span><input type="number" min="1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label>
<label><span>Daily boxes</span><input type="number" min="1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label>
<label><span>Active boxes</span><input type="number" min="1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label>
<label><span>Short-window requests</span><input type="number" min="1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label>
</div>
<button class="button button-primary" type="submit">Save user</button>
</form>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Password reset</h2>
<p>Create a copyable reset link for this user.</p>
</div>
</div>
<form action="/admin/users/{{.Data.UserEdit.ID}}/reset?next=edit" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Generate reset link</button>
</form>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -8,6 +8,7 @@
<a class="sidebar-link" href="/admin/files">Files</a> <a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link is-active" href="/admin/users">Users</a> <a class="sidebar-link is-active" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a> <a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/admin/storage">Storage</a>
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -15,6 +16,7 @@
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post"> <form class="sidebar-logout" action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Sign out</button> <button class="button button-outline" type="submit">Sign out</button>
</form> </form>
</aside> </aside>
@@ -43,6 +45,7 @@
</div> </div>
{{end}} {{end}}
<form class="inline-controls" action="/admin/invites" method="post"> <form class="inline-controls" action="/admin/invites" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label><span>Email</span><input type="email" name="email" required></label> <label><span>Email</span><input type="email" name="email" required></label>
<label><span>Role</span><select name="role"><option value="user">User</option><option value="admin">Admin</option></select></label> <label><span>Role</span><select name="role"><option value="user">User</option><option value="admin">Admin</option></select></label>
<button class="button button-primary" type="submit">Create invite</button> <button class="button button-primary" type="submit">Create invite</button>
@@ -66,6 +69,7 @@
<th>Status</th> <th>Status</th>
<th>Storage</th> <th>Storage</th>
<th>Today</th> <th>Today</th>
<th>Storage backend</th>
<th>Joined</th> <th>Joined</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -79,28 +83,29 @@
<td><span class="badge {{if eq .Status "active"}}badge-active{{else}}badge-disabled{{end}}">{{.Status}}</span></td> <td><span class="badge {{if eq .Status "active"}}badge-active{{else}}badge-disabled{{end}}">{{.Status}}</span></td>
<td>{{.StorageUsed}} / {{.StorageQuota}}</td> <td>{{.StorageUsed}} / {{.StorageQuota}}</td>
<td>{{.DailyUsed}}</td> <td>{{.DailyUsed}}</td>
<td>{{.StorageBackend}}</td>
<td>{{.CreatedAt}}</td> <td>{{.CreatedAt}}</td>
<td class="table-actions"> <td class="table-actions">
{{if eq .Status "disabled"}} {{if eq .Status "disabled"}}
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post"> <form action="/admin/users/{{.ID}}/disable?disabled=false" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-outline button-sm" type="submit">Reactivate</button> <button class="button button-outline button-sm" type="submit">Reactivate</button>
</form> </form>
{{else}} {{else}}
<form action="/admin/users/{{.ID}}/disable" method="post"> <form action="/admin/users/{{.ID}}/disable" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit">Disable</button> <button class="button button-danger button-sm" type="submit">Disable</button>
</form> </form>
{{end}} {{end}}
<form action="/admin/users/{{.ID}}/reset" method="post"> <form action="/admin/users/{{.ID}}/reset" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-outline button-sm" type="submit">Reset link</button> <button class="button button-outline button-sm" type="submit">Reset link</button>
</form> </form>
<form class="quota-form" action="/admin/users/{{.ID}}/quota" method="post"> <a class="button button-outline button-sm" href="/admin/users/{{.ID}}/edit">Edit</a>
<input name="storage_quota_mb" placeholder="Quota MB" title="Override storage quota in MB (leave blank to clear override)">
<button class="button button-outline button-sm" type="submit">Set</button>
</form>
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="8" class="muted-copy">No users yet.</td></tr> <tr><td colspan="9" class="muted-copy">No users yet.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>

View File

@@ -9,6 +9,7 @@
<h1 id="auth-title">Create the admin account</h1> <h1 id="auth-title">Create the admin account</h1>
<p class="muted-copy">The first user becomes the instance admin. Registration closes after this account is created.</p> <p class="muted-copy">The first user becomes the instance admin. Registration closes after this account is created.</p>
<form class="stack-form" action="/register" method="post"> <form class="stack-form" action="/register" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}} {{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<label><span>Username</span><input name="username" autocomplete="username" required></label> <label><span>Username</span><input name="username" autocomplete="username" required></label>
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label> <label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
@@ -20,6 +21,7 @@
<h1 id="auth-title">{{if .Data.IsReset}}Choose a new password{{else}}Create your account{{end}}</h1> <h1 id="auth-title">{{if .Data.IsReset}}Choose a new password{{else}}Create your account{{end}}</h1>
{{if .Data.Email}}<p class="muted-copy">{{.Data.Email}}</p>{{end}} {{if .Data.Email}}<p class="muted-copy">{{.Data.Email}}</p>{{end}}
<form class="stack-form" action="/invite/{{.Data.Token}}" method="post"> <form class="stack-form" action="/invite/{{.Data.Token}}" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}} {{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
{{if not .Data.IsReset}}<label><span>Username</span><input name="username" autocomplete="username" required></label>{{end}} {{if not .Data.IsReset}}<label><span>Username</span><input name="username" autocomplete="username" required></label>{{end}}
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label> <label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
@@ -29,6 +31,7 @@
<p class="kicker">Account</p> <p class="kicker">Account</p>
<h1 id="auth-title">Sign in</h1> <h1 id="auth-title">Sign in</h1>
<form class="stack-form" action="/login" method="post"> <form class="stack-form" action="/login" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}} {{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<input type="hidden" name="next" value="{{.Data.ReturnPath}}"> <input type="hidden" name="next" value="{{.Data.ReturnPath}}">
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label> <label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>

View File

@@ -10,6 +10,7 @@
</nav> </nav>
<hr class="sidebar-sep"> <hr class="sidebar-sep">
<form class="sidebar-logout" action="/logout" method="post"> <form class="sidebar-logout" action="/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button class="button button-outline" type="submit">Sign out</button> <button class="button button-outline" type="submit">Sign out</button>
</form> </form>
</aside> </aside>
@@ -35,6 +36,7 @@
<summary class="button button-outline button-sm">+ Collection</summary> <summary class="button button-outline button-sm">+ Collection</summary>
<div class="new-collection-body"> <div class="new-collection-body">
<form action="/app/collections" method="post"> <form action="/app/collections" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<label> <label>
<span>Name</span> <span>Name</span>
<input name="name" placeholder="e.g. Projects" required> <input name="name" placeholder="e.g. Projects" required>
@@ -73,6 +75,7 @@
<details class="row-edit"> <details class="row-edit">
<summary>Rename</summary> <summary>Rename</summary>
<form action="/app/boxes/{{.ID}}/rename" method="post" class="row-edit-form"> <form action="/app/boxes/{{.ID}}/rename" method="post" class="row-edit-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input name="title" placeholder="New title"> <input name="title" placeholder="New title">
<button class="button button-outline button-sm" type="submit">Save</button> <button class="button button-outline button-sm" type="submit">Save</button>
</form> </form>
@@ -83,6 +86,7 @@
<details class="row-edit"> <details class="row-edit">
<summary>Move</summary> <summary>Move</summary>
<form action="/app/boxes/{{.ID}}/move" method="post" class="row-edit-form"> <form action="/app/boxes/{{.ID}}/move" method="post" class="row-edit-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<select name="collection_id"> <select name="collection_id">
<option value="">Unsorted</option> <option value="">Unsorted</option>
{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}} {{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
@@ -97,6 +101,7 @@
<td class="table-actions"> <td class="table-actions">
<a class="button button-outline button-sm" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a> <a class="button button-outline button-sm" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a>
<form action="/app/boxes/{{.ID}}/delete" method="post"> <form action="/app/boxes/{{.ID}}/delete" method="post">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button class="button button-danger button-sm" type="submit">Delete</button> <button class="button button-danger button-sm" type="submit">Delete</button>
</form> </form>
</td> </td>

View File

@@ -16,6 +16,17 @@ WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
WARPBOX_USER_DAILY_UPLOAD_MB=8192 WARPBOX_USER_DAILY_UPLOAD_MB=8192
WARPBOX_DEFAULT_USER_STORAGE_MB=51200 WARPBOX_DEFAULT_USER_STORAGE_MB=51200
WARPBOX_USAGE_RETENTION_DAYS=30 WARPBOX_USAGE_RETENTION_DAYS=30
WARPBOX_LOCAL_STORAGE_MAX_GB=100
WARPBOX_ANONYMOUS_MAX_DAYS=30
WARPBOX_USER_MAX_DAYS=90
WARPBOX_ANONYMOUS_DAILY_BOXES=100
WARPBOX_USER_DAILY_BOXES=250
WARPBOX_ANONYMOUS_ACTIVE_BOXES=500
WARPBOX_USER_ACTIVE_BOXES=1000
WARPBOX_SHORT_WINDOW_REQUESTS=60
WARPBOX_SHORT_WINDOW_SECONDS=60
WARPBOX_ANONYMOUS_STORAGE_BACKEND=local
WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_IDLE_TIMEOUT=120s WARPBOX_IDLE_TIMEOUT=120s