3 Commits

Author SHA1 Message Date
0503fad9af feat(admin): redesign storage backend management UI
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m31s
Implement a new card-based UI for managing storage backends in the admin panel. This update improves the visual presentation and usability of the storage configuration page.

Key changes:
- Added comprehensive CSS styles for storage cards, including status indicators, metadata layouts, and action buttons.
- Updated the storage admin template to render storage configurations as cards with type-specific details (Local, S3, SFTP, SMB, WebDAV).
- Integrated inline actions for testing, editing, disabling, and deleting storage backends.
- Enhanced sidebar link alignment with flexbox.
2026-05-31 04:54:27 +03:00
3423c141be Update 2026-05-31 04:02:28 +03:00
c3558fd353 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.
2026-05-31 02:14:10 +03:00
1707 changed files with 13323 additions and 195 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,32 @@ module warpbox.dev/backend
go 1.26 go 1.26
require ( require (
github.com/hirochachacha/go-smb2 v1.1.0
github.com/minio/minio-go/v7 v7.2.0
github.com/pkg/sftp v1.13.10
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/geoffgarside/ber v1.1.0 // 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/kr/fs v0.1.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,82 @@
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/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
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/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
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/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
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/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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,145 @@ 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",
Host: r.FormValue("host"),
Port: parsePositiveInt(r.FormValue("port")),
Username: r.FormValue("username"),
Password: r.FormValue("password"),
PrivateKey: r.FormValue("private_key"),
HostKey: r.FormValue("host_key"),
RemotePath: r.FormValue("remote_path"),
Share: r.FormValue("share"),
Domain: r.FormValue("domain"),
})
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",
Host: r.FormValue("host"),
Port: parsePositiveInt(r.FormValue("port")),
Username: r.FormValue("username"),
Password: r.FormValue("password"),
PrivateKey: r.FormValue("private_key"),
HostKey: r.FormValue("host_key"),
RemotePath: r.FormValue("remote_path"),
Share: r.FormValue("share"),
Domain: r.FormValue("domain"),
})
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 +553,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 +662,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 +675,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 +683,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 +827,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()

File diff suppressed because it is too large Load Diff

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,125 @@ 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 TestSFTPStorageConfigValidation(t *testing.T) {
service := newTestUploadService(t)
cfg, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderSFTP,
Name: "NAS storage",
Host: "files.example.test",
Username: "warpbox",
Password: "secret",
RemotePath: "/srv/warpbox//",
})
if err != nil {
t.Fatalf("CreateS3Backend returned error: %v", err)
}
if cfg.Type != StorageBackendSFTP || cfg.Provider != StorageProviderSFTP {
t.Fatalf("sftp config type/provider = %+v", cfg)
}
if cfg.Port != 22 {
t.Fatalf("port = %d, want 22", cfg.Port)
}
if cfg.RemotePath != "/srv/warpbox" {
t.Fatalf("remote path = %q", cfg.RemotePath)
}
}
func TestSMBAndWebDAVStorageConfigValidation(t *testing.T) {
service := newTestUploadService(t)
smb, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderSMB,
Name: "Office NAS",
Host: "nas.example.test",
Username: "warpbox",
Password: "secret",
Share: "uploads",
RemotePath: "/warpbox//",
})
if err != nil {
t.Fatalf("CreateS3Backend smb returned error: %v", err)
}
if smb.Type != StorageBackendSMB || smb.Provider != StorageProviderSMB || smb.Port != 445 {
t.Fatalf("smb config was not normalized: %+v", smb)
}
if smb.RemotePath != "/warpbox" {
t.Fatalf("smb remote path = %q", smb.RemotePath)
}
webdav, err := service.Storage().CreateS3Backend(StorageBackendConfig{
Provider: StorageProviderWebDAV,
Name: "Nextcloud",
Endpoint: "https://files.example.test/webdav",
Username: "warpbox",
Password: "secret",
RemotePath: "/warpbox",
})
if err != nil {
t.Fatalf("CreateS3Backend webdav returned error: %v", err)
}
if webdav.Type != StorageBackendWebDAV || webdav.Provider != StorageProviderWebDAV {
t.Fatalf("webdav config was not normalized: %+v", webdav)
}
}
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

@@ -251,6 +251,9 @@ h1 {
} }
.sidebar-link { .sidebar-link {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.62rem 0.75rem; padding: 0.62rem 0.75rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
@@ -1213,7 +1216,8 @@ pre code {
.table-actions { .table-actions {
display: flex; display: flex;
align-items: center; align-items: flex-start;
flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
@@ -1414,6 +1418,47 @@ pre code {
padding: 0.25rem 0.55rem; padding: 0.25rem 0.55rem;
} }
.storage-edit-form {
width: min(34rem, 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: none;
}
.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 textarea {
min-height: 5rem;
resize: vertical;
}
.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);
@@ -1528,3 +1573,248 @@ pre code {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* ── Storage card UI ─────────────────────────────────────────────────────── */
.storage-stack {
display: grid;
gap: 0.85rem;
}
.storage-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 94%, transparent);
overflow: hidden;
}
.storage-card.is-local {
border-left: 3px solid rgba(125, 211, 252, 0.45);
}
.storage-card.is-editing {
border-color: rgba(125, 211, 252, 0.35);
box-shadow: 0 0 0 1px rgba(125, 211, 252, 0.12);
}
.storage-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.1rem;
flex-wrap: wrap;
}
.storage-card-identity {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.storage-card-icon {
display: grid;
place-items: center;
flex-shrink: 0;
width: 2.4rem;
height: 2.4rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 0.125rem);
background: var(--muted);
color: var(--muted-foreground);
}
.storage-card-icon svg {
width: 1.2rem;
height: 1.2rem;
}
.storage-card-name {
display: block;
font-size: 0.95rem;
font-weight: 650;
color: var(--foreground);
}
.storage-card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.3rem;
}
.storage-card-usage {
color: var(--muted-foreground);
font-size: 0.78rem;
}
.storage-card-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
/* View-mode summary */
.storage-card-summary {
display: flex;
flex-wrap: wrap;
gap: 0 1.75rem;
padding: 0.65rem 1.1rem 0.9rem;
border-top: 1px solid var(--border);
}
.storage-detail {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 8rem;
}
.storage-detail > span:first-child,
.storage-detail > code:first-child {
color: var(--muted-foreground);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.storage-detail > span:last-child,
.storage-detail > code:last-child {
font-size: 0.82rem;
color: var(--foreground);
word-break: break-all;
}
.storage-detail-test > span:last-child {
font-size: 0.8rem;
}
.storage-detail-test.is-ok > span:last-child { color: #86efac; }
.storage-detail-test.is-err > span:last-child { color: #fca5a5; }
/* Edit-mode body */
.storage-card:not(.is-editing) .storage-card-body { display: none; }
.storage-card.is-editing .storage-card-summary { display: none; }
.storage-card.is-editing .storage-edit-trigger { display: none; }
.storage-card-body {
border-top: 1px solid var(--border);
padding: 1rem 1.1rem;
}
.storage-card-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
align-items: end;
}
.storage-card-fields label {
display: grid;
gap: 0.28rem;
color: var(--muted-foreground);
font-size: 0.8rem;
}
.storage-card-fields label span {
font-size: 0.72rem;
color: var(--muted-foreground);
}
.storage-card-fields textarea {
min-height: 5rem;
resize: vertical;
}
.storage-card-fields .checkbox-field {
align-self: center;
}
.storage-card-edit-bar {
grid-column: 1 / -1;
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
padding-top: 0.65rem;
border-top: 1px solid var(--border);
}
@media (max-width: 640px) {
.storage-card-fields {
grid-template-columns: 1fr;
}
}
/* Add storage section */
.storage-add-section {
display: grid;
gap: 0.75rem;
}
.storage-add-controls {
display: flex;
align-items: center;
gap: 0.65rem;
}
.storage-type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 0.6rem;
}
.storage-type-option {
display: grid;
grid-template-rows: auto auto auto;
gap: 0.3rem;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
color: var(--foreground);
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.storage-type-option:hover {
border-color: rgba(125, 211, 252, 0.35);
background: color-mix(in srgb, var(--card) 80%, rgba(14, 116, 144, 0.3));
}
.storage-type-option svg {
width: 1.5rem;
height: 1.5rem;
color: var(--muted-foreground);
margin-bottom: 0.2rem;
}
.storage-type-option strong {
font-size: 0.88rem;
font-weight: 650;
}
.storage-type-option span {
font-size: 0.78rem;
color: var(--muted-foreground);
line-height: 1.4;
}
.storage-new-card {
border: 1px dashed rgba(125, 211, 252, 0.4);
border-radius: var(--radius);
background: color-mix(in srgb, var(--card) 90%, rgba(14, 116, 144, 0.15));
}
.storage-new-card .storage-card-header {
border-bottom: 1px solid var(--border);
}
.storage-new-card .storage-card-body {
border-top: none;
}

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 12.5L18.5 12L17 18.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 12.5L16 7.5L12.5 5L10 7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 6.5C17.3954 6.5 16.5 5.60457 16.5 4.5C16.5 3.39543 17.3954 2.5 18.5 2.5C19.6046 2.5 20.5 3.39543 20.5 4.5C20.5 5.60457 19.6046 6.5 18.5 6.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.49951 12.5C6.33526 11.8721 7.37418 11.5 8.5 11.5C11.2614 11.5 13.5 13.7386 13.5 16.5C13.5 17.2111 13.3516 17.8875 13.084 18.5M3.7289 15C3.58018 15.4735 3.5 15.9774 3.5 16.5C3.5 19.2614 5.73858 21.5 8.5 21.5C9.41072 21.5 10.2646 21.2565 11 20.8311" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 902 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 19V5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19Z" stroke="currentColor"/>
<path d="M12.5 12.1605L16.5 12L16 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.8333 12L13.5 9.53846L10.8333 8L9.5 9.84615" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 7.5C15.2239 7.5 15 7.27614 15 7C15 6.72386 15.2239 6.5 15.5 6.5C15.7761 6.5 16 6.72386 16 7C16 7.27614 15.7761 7.5 15.5 7.5Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 18C8.84315 18 7.5 16.6569 7.5 15C7.5 13.3431 8.84315 12 10.5 12C12.1569 12 13.5 13.3431 13.5 15C13.5 16.6569 12.1569 18 10.5 18Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 9L12 10M17 9L12 10M12 10V13M12 13L10 18M12 13L14 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 7C11.7239 7 11.5 6.77614 11.5 6.5C11.5 6.22386 11.7239 6 12 6C12.2761 6 12.5 6.22386 12.5 6.5C12.5 6.77614 12.2761 7 12 7Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 681 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12H6L9 3L15 21L18 12H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 13V12C14 10.8954 14.8954 10 16 10V10C17.1046 10 18 10.8954 18 12V13H14ZM14 13V14C14 15.1046 14.8954 16 16 16H17.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 16L7.125 13M12 16L10.875 13M7.125 13L9 8L10.875 13M7.125 13L10.875 13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 12L16 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 8.99977L16 9.00977" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 16L8.125 13M13 16L11.875 13M8.125 13L10 8L11.875 13M8.125 13L11.875 13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 8L8.5 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 12V15.4C15.5 15.7314 15.2314 16 14.9 16H13.5C12.3954 16 11.5 15.1046 11.5 14V14C11.5 12.8954 12.3954 12 13.5 12H15.5ZM15.5 12V9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 634 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 8L7 16L11 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 10.5L14 13M14 16L14 13M14 13C14 13 14 10.5 17 10.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 16L7 12M7 12L7 8L9 8C10.1046 8 11 8.89543 11 10V10C11 11.1046 10.1046 12 9 12L7 12Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 11V11C16.6936 10.3871 16.0672 10 15.382 10H15C14.1716 10 13.5 10.6716 13.5 11.5V11.5C13.5 12.3284 14.1716 13 15 13H15.5C16.3284 13 17 13.6716 17 14.5V14.5C17 15.3284 16.3284 16 15.5 16H15.118C14.4328 16 13.8064 15.6129 13.5 15V15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 807 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 7V17C21 19.2091 19.2091 21 17 21H7C4.79086 21 3 19.2091 3 17V7C3 4.79086 4.79086 3 7 3H17C19.2091 3 21 4.79086 21 7Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 8L11 16M7 16L11 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 12V15.4C17 15.7314 16.7314 16 16.4 16H15C13.8954 16 13 15.1046 13 14V14C13 12.8954 13.8954 12 15 12H17ZM17 12V9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22L12 12M12 8L12 12M12 12L15 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.4243 18.5757L18.593 12.4071C20.9331 10.0669 20.6927 6.2053 18.0804 4.17349C14.5041 1.39191 9.49616 1.39192 5.91984 4.1735C3.3075 6.20532 3.06707 10.067 5.40723 12.4071L11.5758 18.5757C11.8101 18.81 12.19 18.81 12.4243 18.5757Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 550 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 19H22M22 19L19.5 16.5M22 19L19.5 21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 2L9.5 4.5L12 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 4.5C14.6421 4.5 18 7.85786 18 12C18 16.1421 14.6421 19.5 10.5 19.5H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.75583 5.5C4.51086 6.79595 3 9.22154 3 12C3 13.6884 3.55792 15.2465 4.49945 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

View File

@@ -0,0 +1,9 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 3.6V11H2V3.6C2 3.26863 2.26863 3 2.6 3H21.4C21.7314 3 22 3.26863 22 3.6Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 7H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 11L2.78969 13.5844C3.04668 14.4255 3.82294 15 4.70239 15H6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 11L21.2103 13.5844C20.9533 14.4255 20.1771 15 19.2976 15H18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 14.5C9.5 14.5 9.5 21.5 6 21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 14.5C14.5 14.5 14.5 21.5 18 21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 14.5V21.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.1207 14.1213C15.2922 12.9497 15.2922 11.0503 14.1207 9.87868C12.9491 8.70711 11.0496 8.70711 9.87803 9.87868C8.70646 11.0503 8.70646 12.9497 9.87803 14.1213C11.0496 15.2929 12.9491 15.2929 14.1207 14.1213Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.87868 9.87863C9.87868 9.87863 7.07642 9.88782 5.63604 8.46441C4.22749 7.05444 2.77156 5.67063 4.22183 4.22177C5.59998 2.84504 7.03117 4.20692 8.46447 5.63599C9.8702 7.03747 9.87868 9.87863 9.87868 9.87863Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1214 9.87868C14.1214 9.87868 14.1122 7.07642 15.5356 5.63604C16.9456 4.22749 18.3294 2.77156 19.7782 4.22183C21.155 5.59998 19.7931 7.03117 18.364 8.46447C16.9625 9.8702 14.1214 9.87868 14.1214 9.87868Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.87863 14.1213C9.87863 14.1213 9.88782 16.9236 8.46441 18.364C7.05444 19.7725 5.67063 21.2284 4.22177 19.7782C2.84504 18.4 4.20692 16.9688 5.63599 15.5355C7.03747 14.1298 9.87863 14.1213 9.87863 14.1213Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1213 14.1214C14.1213 14.1214 16.9236 14.1122 18.364 15.5356C19.7725 16.9456 21.2284 18.3294 19.7782 19.7782C18.4 21.155 16.9688 19.7931 15.5355 18.364C14.1298 16.9625 14.1213 14.1214 14.1213 14.1214Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9996 14.9995C13.6565 14.9995 14.9996 13.6564 14.9996 11.9995C14.9996 10.3427 13.6565 8.99951 11.9996 8.99951C10.3428 8.99951 8.99963 10.3427 8.99963 11.9995C8.99963 13.6564 10.3428 14.9995 11.9996 14.9995Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 9C12 9 10.012 7.025 10 5C10.001 3.007 9.95 0.999 12 1C13.948 1.001 13.997 2.976 14 5C14.003 6.985 12 9 12 9Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 12C15 12 16.975 10.012 19 10C20.993 10.001 23.001 9.95 23 12C22.999 13.948 21.024 13.997 19 14C17.015 14.003 15 12 15 12Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 12C9 12 7.025 13.988 5 14C3.007 13.999 0.999 14.05 1 12C1.001 10.052 2.976 10.003 5 10C6.985 9.997 9 12 9 12Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 15C12 15 13.988 16.975 14 19C13.999 20.993 14.05 23.001 12 23C10.052 22.999 10.003 21.024 10 19C9.997 17.015 12 15 12 15Z" stroke="currentColor" stroke-miterlimit="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.88099 9.88688L2.782 14.3237C2.60657 14.4334 2.5 14.6257 2.5 14.8325V15.7315C2.5 16.1219 2.86683 16.4083 3.24552 16.3136L9.75448 14.6864C10.1332 14.5917 10.5 14.8781 10.5 15.2685V18.2277C10.5 18.4008 10.4253 18.5654 10.2951 18.6793L8.13481 20.5695C7.6765 20.9706 8.03808 21.7203 8.63724 21.6114L11.8927 21.0195C11.9636 21.0066 12.0364 21.0066 12.1073 21.0195L15.3628 21.6114C15.9619 21.7203 16.3235 20.9706 15.8652 20.5695L13.7049 18.6793C13.5747 18.5654 13.5 18.4008 13.5 18.2277V15.2685C13.5 14.8781 13.8668 14.5917 14.2455 14.6864L14.7029 14.8007M10.5 7.5V4.5C10.5 3.67157 11.1716 3 12 3V3C12.8284 3 13.5 3.67157 13.5 4.5V9.16745C13.5 9.37433 13.6066 9.56661 13.782 9.67625L21.218 14.3237C21.3934 14.4334 21.5 14.6257 21.5 14.8325V15.7315C21.5 16.1219 21.1332 16.4083 20.7545 16.3136L18.7493 15.8123" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 3L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.87868 14.1218C11.0503 15.2934 12.9497 15.2934 14.1213 14.1218C15.2929 12.9502 15.2929 11.0507 14.1213 9.87913C12.9497 8.70756 11.0503 8.70756 9.87868 9.87913C8.70711 11.0507 8.7071 12.9502 9.87868 14.1218Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.37076 16.7726C4.09132 16.3274 3.84879 15.8547 3.64986 15.3612C3.23116 14.323 3.00098 13.1891 3.00098 12.0012C3.00098 7.7649 5.93471 4.20879 9.8792 3.25392C10.5594 3.0891 11.2698 3.00195 12.0002 3.00195" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.7148 7.3667C20.5304 8.72132 20.9993 10.3061 20.9993 12.0008C20.9993 15.807 18.6311 19.0638 15.29 20.3786C14.2708 20.7793 13.1605 21 12.0001 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1213 9.87918C14.1213 9.87918 14.1121 7.07691 15.5355 5.63653C16.9455 4.22798 18.3293 2.77204 19.7782 4.22232C21.1549 5.60047 19.793 7.03166 18.364 8.46496C16.9625 9.87069 14.1213 9.87918 14.1213 9.87918Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.87869 14.1208C9.87869 14.1208 9.88788 16.9231 8.46448 18.3635C7.0545 19.772 5.6707 21.228 4.22183 19.7777C2.8451 18.3995 4.20698 16.9683 5.63605 15.535C7.03753 14.1293 9.87869 14.1208 9.87869 14.1208Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5 4.5V9.16745C10.5 9.37433 10.3934 9.56661 10.218 9.67625L2.782 14.3237C2.60657 14.4334 2.5 14.6257 2.5 14.8325V15.7315C2.5 16.1219 2.86683 16.4083 3.24552 16.3136L9.75448 14.6864C10.1332 14.5917 10.5 14.8781 10.5 15.2685V18.2277C10.5 18.4008 10.4253 18.5654 10.2951 18.6793L8.13481 20.5695C7.6765 20.9706 8.03808 21.7204 8.63724 21.6114L11.8927 21.0195C11.9636 21.0066 12.0364 21.0066 12.1073 21.0195L15.3628 21.6114C15.9619 21.7204 16.3235 20.9706 15.8652 20.5695L13.7049 18.6793C13.5747 18.5654 13.5 18.4008 13.5 18.2277V15.2685C13.5 14.8781 13.8668 14.5917 14.2455 14.6864L20.7545 16.3136C21.1332 16.4083 21.5 16.1219 21.5 15.7315V14.8325C21.5 14.6257 21.3934 14.4334 21.218 14.3237L13.782 9.67625C13.6066 9.56661 13.5 9.37433 13.5 9.16745V4.5C13.5 3.67157 12.8284 3 12 3C11.1716 3 10.5 3.67157 10.5 4.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 17L3 17L3 4L21 4L21 17L18 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.62188 19.0672L11.5008 14.7488C11.7383 14.3926 12.2617 14.3926 12.4992 14.7488L15.3781 19.0672C15.6439 19.4659 15.3581 20 14.8789 20H9.12111C8.64189 20 8.35606 19.4659 8.62188 19.0672Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 13H12V8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 3.5L7 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 3.5L17 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 22C16.9706 22 21 17.9706 21 13C21 8.02944 16.9706 4 12 4C7.02944 4 3 8.02944 3 13C3 17.9706 7.02944 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 596 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 19.4V4.6C2 4.26863 2.26863 4 2.6 4H17.4C17.7314 4 18 4.26863 18 4.6V19.4C18 19.7314 17.7314 20 17.4 20H2.6C2.26863 20 2 19.7314 2 19.4Z" stroke="currentColor"/>
<path d="M22 6V18" stroke="currentColor" stroke-linecap="round"/>
<path d="M11 14.5C11 15.3284 10.3284 16 9.5 16C8.67157 16 8 15.3284 8 14.5C8 13.6716 8.67157 13 9.5 13C10.3284 13 11 13.6716 11 14.5ZM11 14.5V8.6C11 8.26863 11.2686 8 11.6 8H13" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 17.4V2.6C2 2.26863 2.26863 2 2.6 2H17.4C17.7314 2 18 2.26863 18 2.6V17.4C18 17.7314 17.7314 18 17.4 18H2.6C2.26863 18 2 17.7314 2 17.4Z" stroke="currentColor"/>
<path d="M8 22H21.4C21.7314 22 22 21.7314 22 21.4V8" stroke="currentColor" stroke-linecap="round"/>
<path d="M11 12.5C11 13.3284 10.3284 14 9.5 14C8.67157 14 8 13.3284 8 12.5C8 11.6716 8.67157 11 9.5 11C10.3284 11 11 11.6716 11 12.5ZM11 12.5V6.6C11 6.26863 11.2686 6 11.6 6H13" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 2.20001C19.5645 3.12655 23 7.16206 23 12C23 16.8379 19.5645 20.8734 15 21.7999" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 9C16.1411 9.28364 17 10.519 17 12C17 13.481 16.1411 14.7164 15 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 2L11 2L11 22L1 22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 15.5C4 16.3284 3.32843 17 2.5 17C1.67157 17 1 16.3284 1 15.5C1 14.6716 1.67157 14 2.5 14C3.32843 14 4 14.6716 4 15.5ZM4 15.5V7.6C4 7.26863 4.26863 7 4.6 7H7" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 756 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z" stroke="currentColor"/>
<path d="M12 15.5C12 16.3284 11.3284 17 10.5 17C9.67157 17 9 16.3284 9 15.5C9 14.6716 9.67157 14 10.5 14C11.3284 14 12 14.6716 12 15.5ZM12 15.5V7.6C12 7.26863 12.2686 7 12.6 7H15" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 522 B

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 8.00001L4.01 8.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 4.00001L4.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 4.00001L8.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 4.00001L12.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 4.00001L16.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 4.00001L20.01 4.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 8.00001L20.01 8.01112" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12V20H20V12H4Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 10L18 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 18L18 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 487 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22L12 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19 16H5C3.89543 16 3 15.1046 3 14L3 10C3 8.89543 3.89543 8 5 8H19C20.1046 8 21 8.89543 21 10V14C21 15.1046 20.1046 16 19 16Z" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 22L3 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 22V2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 16H9C7.89543 16 7 15.1046 7 14V10C7 8.89543 7.89543 8 9 8H15C16.1046 8 17 8.89543 17 10V14C17 15.1046 16.1046 16 15 16Z" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 461 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 10H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 18H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0041 3.995L15.993 4.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0041 3.995L19.993 4.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0041 7.995L19.993 8.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0041 11.995L19.993 12.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0041 15.995L19.993 16.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0041 19.995L19.993 20.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.0041 19.995L15.993 20.005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.0059 3.995L4.00586 3.995L4.00586 19.995H12.0059L12.0059 3.995Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 10L17 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 6H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 18L17 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 487 B

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00563 20.005L8.01674 19.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00563 20.005L4.01674 19.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00563 16.005L4.01674 15.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00563 12.005L4.01674 11.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00563 8.005L4.01674 7.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00563 4.005L4.01674 3.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.00563 4.005L8.01674 3.995" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.0059 20.005H20.0059V4.005H12.0059V20.005Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 10L21 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 6H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 18L21 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 14H21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 487 B

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 16L4.01 15.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 20L4.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 20L8.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 20L12.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 20L16.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 20L20.01 19.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 16L20.01 15.9889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12V4H20V12H4Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 928 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 12L2 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 19V5C8 3.89543 8.89543 3 10 3H14C15.1046 3 16 3.89543 16 5V19C16 20.1046 15.1046 21 14 21H10C8.89543 21 8 20.1046 8 19Z" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 3L2 3" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 21L2 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 15V9C8 7.89543 8.89543 7 10 7H14C15.1046 7 16 7.89543 16 9V15C16 16.1046 15.1046 17 14 17H10C8.89543 17 8 16.1046 8 15Z" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 21L3 3L9 3V15L21 15V21H3Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 19V21" stroke="currentColor" stroke-linecap="round"/>
<path d="M9 19V21" stroke="currentColor" stroke-linecap="round"/>
<path d="M3 7H5" stroke="currentColor" stroke-linecap="round"/>
<path d="M3 11H5" stroke="currentColor" stroke-linecap="round"/>
<path d="M3 15H5" stroke="currentColor" stroke-linecap="round"/>
<path d="M17 19V21" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5C12.5523 5 13 4.55228 13 4C13 3.44772 12.5523 3 12 3C11.4477 3 11 3.44772 11 4C11 4.55228 11.4477 5 12 5Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 23L8.11111 19M17 23L15.8889 19M9.5 14L8.11111 19M9.5 14H13.5M9.5 14L10.2998 11.1208M8.11111 19H15.8889M15.8889 19L14.7045 14.7361M11.4444 7L12 5L13.0466 8.76759" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 3L21 21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 671 B

View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 15V9C2 5.68629 4.68629 3 8 3H16C19.3137 3 22 5.68629 22 9V15C22 18.3137 19.3137 21 16 21H8C4.68629 21 2 18.3137 2 15Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M15 9C15 9 16 10.125 16 12C16 13.875 15 15 15 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 12.01L12.01 11.9989" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 7C17 7 19 8.78571 19 12C19 15.2143 17 17 17 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9C9 9 8 10.125 8 12C8 13.875 9 15 9 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 7C7 7 5 8.78571 5 12C5 15.2143 7 17 7 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 911 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 8C17.5 8 19 9.5 19 12C19 14.5 17.5 16 17.5 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.5 5C20.5 5 23 7.5 23 12C23 16.5 20.5 19 20.5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.5 8C6.5 8 5 9.5 5 12C5 14.5 6.5 16 6.5 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.5 5C3.5 5 1 7.5 1 12C1 16.5 3.5 19 3.5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 13C12.5523 13 13 12.5523 13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12C11 12.5523 11.4477 13 12 13Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 861 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 5C12.5523 5 13 4.55228 13 4C13 3.44772 12.5523 3 12 3C11.4477 3 11 3.44772 11 4C11 4.55228 11.4477 5 12 5Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 1C16 1 17.5 2 17.5 4C17.5 6 16 7 16 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 1C8 1 6.5 2 6.5 4C6.5 6 8 7 8 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 23L8.11111 19M17 23L15.8889 19M14.5 14L12 5L9.5 14M14.5 14H9.5M14.5 14L15.8889 19M9.5 14L8.11111 19M8.11111 19H15.8889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 776 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 8C20.6569 8 22 6.65685 22 5C22 3.34315 20.6569 2 19 2C17.3431 2 16 3.34315 16 5C16 6.65685 17.3431 8 19 8Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 12V15C21 18.3137 18.3137 21 15 21H9C5.68629 21 3 18.3137 3 15V9C3 5.68629 5.68629 3 9 3H12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 5.5L17.5 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 5.5L6.5 16.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.5 14H6.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.5 14H16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 19V5C2 3.89543 2.89543 3 4 3H20C21.1046 3 22 3.89543 22 5V19C22 20.1046 21.1046 21 20 21H4C2.89543 21 2 20.1046 2 19Z" stroke="currentColor"/>
<path d="M2 7L22 7" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 5.01L5.01 4.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5.01L8.01 4.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 5.01L11.01 4.99889" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.1471 21.2646L12 21.2351L11.8529 21.2646C9.47627 21.7399 7.23257 21.4756 5.59352 20.1643C3.96312 18.86 2.75 16.374 2.75 12C2.75 7.52684 3.75792 5.70955 5.08541 5.04581C5.77977 4.69863 6.67771 4.59759 7.82028 4.72943C8.96149 4.86111 10.2783 5.21669 11.7628 5.71153L12.0235 5.79841L12.2785 5.69638C14.7602 4.70367 16.9909 4.3234 18.5578 5.05463C20.0271 5.7403 21.25 7.59326 21.25 12C21.25 16.374 20.0369 18.86 18.4065 20.1643C16.7674 21.4756 14.5237 21.7399 12.1471 21.2646Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 5.5C12 3 11 2 9 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 6V21" stroke="currentColor" stroke-width="1.5"/>
<path d="M15 12L15 14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 910 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 22L8 22M14 22L8 22M8 22L10 13.5M10 13.5L7 2M10 13.5L11.5 19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 22L18 22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 360 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 15.5V2.6C2 2.26863 2.26863 2 2.6 2H21.4C21.7314 2 22 2.26863 22 2.6V15.5M2 15.5V17.4C2 17.7314 2.26863 18 2.6 18H21.4C21.7314 18 22 17.7314 22 17.4V15.5M2 15.5H22M9 22H10.5M10.5 22V18M10.5 22H13.5M13.5 22H15M13.5 22L13.5 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 430 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 2C16.3632 4.17921 14.0879 5.83084 12.8158 6.57142C12.4406 6.78988 12.0172 6.5117 12.0819 6.08234C12.2993 4.63878 13.0941 2.00008 16 2Z" stroke="currentColor" />
<path d="M9 6.5C9.89676 6.5 10.6905 6.69941 11.2945 6.92013C12.0563 7.19855 12.9437 7.19854 13.7055 6.92012C14.3094 6.6994 15.1032 6.5 15.9999 6.5C17.0852 6.5 18.4649 7.08889 19.4999 8.26666C16 11 17 15.5 20.269 16.6916C19.2253 19.5592 17.2413 21.5 15.4999 21.5C13.9999 21.5 14 20.8 12.5 20.8C11 20.8 11 21.5 9.5 21.5C7 21.5 4 17.5 4 12.5C4 8.5 7 6.5 9 6.5Z" stroke="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.8525 14.6334L3.65151 10.6873C2.41651 9.90141 2.41651 8.09858 3.65151 7.31268L9.8525 3.36659C11.1628 2.53279 12.8372 2.53279 14.1475 3.36659L20.3485 7.31268C21.5835 8.09859 21.5835 9.90142 20.3485 10.6873L14.1475 14.6334C12.8372 15.4672 11.1628 15.4672 9.8525 14.6334Z" stroke="currentColor"/>
<path d="M18.2857 12L20.3485 13.3127C21.5835 14.0986 21.5835 15.9014 20.3485 16.6873L14.1475 20.6334C12.8372 21.4672 11.1628 21.4672 9.8525 20.6334L3.65151 16.6873C2.41651 15.9014 2.41651 14.0986 3.65151 13.3127L5.71429 12" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.457 14.5892C20.9032 13.1527 21.9081 7.84019 14.5261 3.10086C14.2661 2.9345 13.9248 2.97821 13.7186 3.20043C13.5111 3.42264 13.5024 3.75778 13.6974 3.99092C13.7274 4.02614 16.4472 7.33991 15.4798 11.1248C13.8074 9.97369 7.1565 4.70249 7.1565 4.70249L11 11L3.8617 6.40006C3.8617 6.40006 8.90765 12.5953 11.9962 14.9255C10.5013 15.4622 7.25274 16.0305 2.963 13.364C2.72052 13.2122 2.40179 13.2413 2.1918 13.438C1.98431 13.6311 1.93931 13.9395 2.08555 14.1812C2.2293 14.4192 5.66784 20 12.9387 20C14.9335 20 16.0997 19.4317 17.0372 18.9764C17.6134 18.6971 18.0683 18.4749 18.5646 18.4749C19.8007 18.4749 20.6119 19.7025 20.6194 19.7134C20.7344 19.8919 20.9357 20 21.1507 20C21.1669 20 21.1844 19.9988 21.2019 19.9976C21.4356 19.9794 21.6381 19.8373 21.7281 19.6272C22.6206 17.5544 21.0832 15.359 20.457 14.5892Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1015 B

View File

@@ -0,0 +1,6 @@
<svg width="24" stroke-width="1.5" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21Z" stroke="currentColor" />
<path d="M3 15H9.4C9.73137 15 10.0053 15.2783 10.1504 15.5762C10.3564 15.9991 10.8442 16.5 12 16.5C13.1558 16.5 13.6436 15.9991 13.8496 15.5762C13.9947 15.2783 14.2686 15 14.6 15H21" stroke="currentColor" />
<path d="M3 7H21" stroke="currentColor" />
<path d="M3 11H21" stroke="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 574 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.1471 21.2646L12 21.2351L11.8529 21.2646C9.47627 21.7399 7.23257 21.4756 5.59352 20.1643C3.96312 18.86 2.75 16.374 2.75 12C2.75 7.52684 3.75792 5.70955 5.08541 5.04581C5.77977 4.69863 6.67771 4.59759 7.82028 4.72943C8.96149 4.86111 10.2783 5.21669 11.7628 5.71153L12.0235 5.79841L12.2785 5.69638C14.7602 4.70367 16.9909 4.3234 18.5578 5.05463C20.0271 5.7403 21.25 7.59326 21.25 12C21.25 16.374 20.0369 18.86 18.4065 20.1643C16.7674 21.4756 14.5237 21.7399 12.1471 21.2646Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 5.5C12 3 11 2 9 2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 15V9C2 5.68629 4.68629 3 8 3H16C19.3137 3 22 5.68629 22 9V15C22 18.3137 19.3137 21 16 21H8C4.68629 21 2 18.3137 2 15Z" stroke="currentColor" stroke-width="1.5"/>
<path d="M13 15.5V12.7M15.8571 12.7C16.5714 12.7 18 12.7 18 10.6C18 8.5 16.5714 8.5 15.8571 8.5L13 8.5V12.7M15.8571 12.7C14.7143 12.7 13.4762 12.7 13 12.7M15.8571 12.7L18 15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 15.5L9.92857 13M5 15.5L6.07143 13M6.07143 13L8 8.5L9.92857 13M6.07143 13H9.92857" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 710 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.9999 16C21.9999 10.4772 17.5228 6 11.9999 6C7.89931 6 4.37514 8.46819 2.83203 12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 3"/>
<path d="M2 17C2.55228 17 3 16.5523 3 16C3 15.4477 2.55228 15 2 15C1.44772 15 1 15.4477 1 16C1 16.5523 1.44772 17 2 17Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 16H12" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 834 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 16C22 10.4772 17.5228 6 12 6C6.47715 6 2 10.4772 2 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17C2.55228 17 3 16.5523 3 16C3 15.4477 2.55228 15 2 15C1.44772 15 1 15.4477 1 16C1 16.5523 1.44772 17 2 17Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 17C22.5523 17 23 16.5523 23 16C23 15.4477 22.5523 15 22 15C21.4477 15 21 15.4477 21 16C21 16.5523 21.4477 17 22 17Z" fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 8.5L9.8 9L2.35172 12.3856C2.13752 12.4829 2 12.6965 2 12.9318V13.0682C2 13.3035 2.13752 13.5171 2.35172 13.6144L11.1724 17.6238C11.6982 17.8628 12.3018 17.8628 12.8276 17.6238L21.6483 13.6144C21.8625 13.5171 22 13.3035 22 13.0682V12.9318C22 12.6965 21.8625 12.4829 21.6483 12.3856L14.2 9L13 8.5" stroke="currentColor" />
<path d="M22 13V17.112C22 17.3482 21.8615 17.5623 21.6462 17.6592L12.8207 21.6307C12.2988 21.8655 11.7012 21.8655 11.1793 21.6307L2.35378 17.6592C2.13847 17.5623 2 17.3482 2 17.112V13" stroke="currentColor" />
<path d="M12 8C10.3431 8 9 6.65685 9 5C9 3.34315 10.3431 2 12 2C13.6569 2 15 3.34315 15 5C15 6.65685 13.6569 8 12 8Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 8V13C11 13.5523 11.4477 14 12 14V14C12.5523 14 13 13.5523 13 13V8" stroke="currentColor" />
<path d="M16 13H17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.61096 15.8891L20.6318 3.86829M8.61096 15.8891H5.78253L2.9541 18.7175H5.78253V21.546L8.61096 18.7175V15.8891ZM20.6318 3.86829H17.8033M20.6318 3.86829V6.69671" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.3891 15.8891L3.36829 3.86829M15.3891 15.8891H18.2175L21.046 18.7175H18.2175V21.546L15.3891 18.7175V15.8891ZM3.36829 3.86829H6.19672M3.36829 3.86829V6.69671" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12H17M8 12L6 10H2L4 12L2 14H6L8 12ZM17 12L15 10M17 12L15 14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 22.5C18.7614 22.5 21 17.799 21 12C21 6.20101 18.7614 1.5 16 1.5C13.2386 1.5 11 6.20101 11 12C11 17.799 13.2386 22.5 16 22.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 6L17 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 9L17 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 17H15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 12H2.6C2.26863 12 2 12.2686 2 12.6V21.4C2 21.7314 2.26863 22 2.6 22H21.4C21.7314 22 22 21.7314 22 21.4V12.6C22 12.2686 21.7314 12 21.4 12H21M3 12V2.6C3 2.26863 3.26863 2 3.6 2H20.4C20.7314 2 21 2.26863 21 2.6V12M3 12H21" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.1241 20.1185C20.6654 19.5758 21 18.827 21 18C21 16.3431 19.6569 15 18 15C16.3431 15 15 16.3431 15 18C15 19.6569 16.3431 21 18 21C18.8299 21 19.581 20.663 20.1241 20.1185ZM20.1241 20.1185L22 22" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 2H4V5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 11V13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 2H13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 22H13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20 11V13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 2H20V5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 22H4V19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.61096 15.8891L20.6318 3.86829M8.61096 15.8891H5.78253L2.9541 18.7175H5.78253V21.546L8.61096 18.7175V15.8891ZM20.6318 3.86829H17.8033M20.6318 3.86829V6.69671" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 8V16M12 16L15.5 12.5M12 16L8.5 12.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 438 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8284 9.17157L9.17153 14.8284M9.17153 14.8284H14.1213M9.17153 14.8284V9.87867" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.8284 9.17157L9.17153 14.8284M9.17153 14.8284H14.1213M9.17153 14.8284V9.87867" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6L6 19M6 19L6 6.52M6 19H18.48" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.17137 9.17157L14.8282 14.8284M14.8282 14.8284H9.87848M14.8282 14.8284V9.87867" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.17137 9.17157L14.8282 14.8284M14.8282 14.8284H9.87848M14.8282 14.8284V9.87867" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.00005 6.00004L19 19M19 19V6.52004M19 19H6.52005" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 5H18C20.2091 5 22 6.79086 22 9V15C22 17.2091 20.2091 19 18 19H6C3.79086 19 2 17.2091 2 15V9C2 6.79086 3.79086 5 6 5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 10.75L12 13.25L9.5 10.75" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3L12 21M12 21L20.5 12.5M12 21L3.5 12.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 10L8 10C0 10 0 21 8 21M22 10L15 3M22 10L15 17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 9.5L6 12L8.5 14.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.5 9.5L18 12L15.5 14.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 15V9C2 6.79086 3.79086 5 6 5H18C20.2091 5 22 6.79086 22 9V15C22 17.2091 20.2091 19 18 19H6C3.79086 19 2 17.2091 2 15Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 12H8M8 12L11.5 15.5M8 12L11.5 8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.75 12H6.75M6.75 12L9.5 14.75M6.75 12L9.5 9.25" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 15V9C2 6.79086 3.79086 5 6 5H18C20.2091 5 22 6.79086 22 9V15C22 17.2091 20.2091 19 18 19H6C3.79086 19 2 17.2091 2 15Z" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 12L3 12M3 12L11.5 3.5M3 12L11.5 20.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 9.5L9.5 12L7 14.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16.5 9.5L14 12L16.5 14.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 5H18C20.2091 5 22 6.79086 22 9V15C22 17.2091 20.2091 19 18 19H6C3.79086 19 2 17.2091 2 15V9C2 6.79086 3.79086 5 6 5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 532 B

Some files were not shown because too many files have changed in this diff Show More