docs: expand configuration docs for admin and BadgerDB
Update README to explain startup config precedence (defaults/env/admin overrides), document admin/bootstrap and feature toggles, and clarify storage locations under WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to include new config and metastore packages.docs: expand configuration docs for admin and BadgerDB Update README to explain startup config precedence (defaults/env/admin overrides), document admin/bootstrap and feature toggles, and clarify storage locations under WARPBOX_DATA_DIR including BadgerDB metadata. Also refresh project layout to include new config and metastore packages.
This commit is contained in:
49
README.md
49
README.md
@@ -71,36 +71,66 @@ go run ./cmd run --addr :3000
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
WarpBox mostly works with defaults. These environment variables tune polling
|
WarpBox loads defaults, applies environment variables at startup, then applies
|
||||||
and thumbnail generation.
|
safe admin settings overrides from BadgerDB. Hard storage and global limit
|
||||||
|
settings remain environment controlled.
|
||||||
|
|
||||||
| Variable | Default | Minimum | What it does |
|
| Variable | Default | What it does |
|
||||||
| --- | ---: | ---: | --- |
|
| --- | ---: | --- |
|
||||||
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | `1000` | Browser polling interval for box/file status updates. |
|
| `WARPBOX_DATA_DIR` | `./data` | Root directory for uploads and metadata. |
|
||||||
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | `1` | Number of pending thumbnails processed per worker pass. |
|
| `WARPBOX_ADMIN_PASSWORD` | empty | Bootstraps the first admin when set. |
|
||||||
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | `1` | Delay between thumbnail worker passes. |
|
| `WARPBOX_ADMIN_USERNAME` | `admin` | Bootstrap admin username. |
|
||||||
|
| `WARPBOX_ADMIN_EMAIL` | empty | Bootstrap admin email. |
|
||||||
|
| `WARPBOX_ADMIN_ENABLED` | `auto` | Admin login mode: `auto`, `true`, or `false`. |
|
||||||
|
| `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE` | `true` | Allows safe settings overrides from `/admin/settings`. |
|
||||||
|
| `WARPBOX_ADMIN_COOKIE_SECURE` | `false` | Sets the Secure flag on admin session cookies. |
|
||||||
|
| `WARPBOX_GUEST_UPLOADS_ENABLED` | `true` | Enables guest uploads. |
|
||||||
|
| `WARPBOX_API_ENABLED` | `true` | Enables JSON/upload endpoints used by the UI. |
|
||||||
|
| `WARPBOX_ZIP_DOWNLOADS_ENABLED` | `true` | Enables ZIP downloads. |
|
||||||
|
| `WARPBOX_ONE_TIME_DOWNLOADS_ENABLED` | `true` | Enables one-time download boxes. |
|
||||||
|
| `WARPBOX_RENEW_ON_ACCESS_ENABLED` | `false` | Renews expiring boxes on access. |
|
||||||
|
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
|
||||||
|
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
|
||||||
|
| `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. |
|
||||||
|
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` | `0` | Hard per-file cap; `0` means unlimited. |
|
||||||
|
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` | `0` | Hard per-box cap; `0` means unlimited. |
|
||||||
|
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` | `0` | Default user file cap. |
|
||||||
|
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` | `0` | Default user box cap. |
|
||||||
|
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
|
||||||
|
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. |
|
||||||
|
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
|
||||||
|
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
WARPBOX_ADMIN_PASSWORD='change-me' \
|
||||||
WARPBOX_BOX_POLL_INTERVAL_MS=2000 \
|
WARPBOX_BOX_POLL_INTERVAL_MS=2000 \
|
||||||
WARPBOX_THUMBNAIL_BATCH_SIZE=20 \
|
WARPBOX_THUMBNAIL_BATCH_SIZE=20 \
|
||||||
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \
|
WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \
|
||||||
go run ./cmd run --addr :8080
|
go run ./cmd run --addr :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open `/admin/login` after startup to sign in with the bootstrap admin.
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
Uploads are stored locally under:
|
Uploads are stored locally under:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/uploads/
|
<WARPBOX_DATA_DIR>/uploads/
|
||||||
```
|
```
|
||||||
|
|
||||||
Each box gets its own directory containing the uploaded files and a
|
Each box gets its own directory containing the uploaded files and a
|
||||||
`.warpbox.json` manifest. Image thumbnails are stored inside a box-local
|
`.warpbox.json` manifest. Image thumbnails are stored inside a box-local
|
||||||
`.thumbnails` directory.
|
`.thumbnails` directory.
|
||||||
|
|
||||||
|
Persistent app metadata lives in BadgerDB under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<WARPBOX_DATA_DIR>/db/
|
||||||
|
```
|
||||||
|
|
||||||
```text
|
```text
|
||||||
data/uploads/
|
data/uploads/
|
||||||
+-- <box-id>/
|
+-- <box-id>/
|
||||||
@@ -108,6 +138,7 @@ data/uploads/
|
|||||||
+-- file.txt
|
+-- file.txt
|
||||||
+-- .thumbnails/
|
+-- .thumbnails/
|
||||||
+-- <file-id>.jpg
|
+-- <file-id>.jpg
|
||||||
|
data/db/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
@@ -117,6 +148,8 @@ cmd/ CLI entrypoint
|
|||||||
lib/server/ HTTP handlers and server setup
|
lib/server/ HTTP handlers and server setup
|
||||||
lib/routing/ Route registration
|
lib/routing/ Route registration
|
||||||
lib/boxstore/ Box storage, manifests, downloads, thumbnails
|
lib/boxstore/ Box storage, manifests, downloads, thumbnails
|
||||||
|
lib/config/ Typed environment and runtime settings config
|
||||||
|
lib/metastore/ BadgerDB metadata store for users, tags, settings, sessions
|
||||||
lib/helpers/ Small shared helpers
|
lib/helpers/ Small shared helpers
|
||||||
lib/models/ Shared request/response models
|
lib/models/ Shared request/response models
|
||||||
templates/ Server-rendered HTML
|
templates/ Server-rendered HTML
|
||||||
|
|||||||
47
docs/tech.md
47
docs/tech.md
@@ -86,13 +86,45 @@ Tuning is done with:
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Runtime configuration is intentionally small.
|
Runtime configuration is centralized in `lib/config`. Startup applies built-in
|
||||||
|
defaults, environment variables, then safe BadgerDB settings overrides.
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
Storage paths are derived from `WARPBOX_DATA_DIR`:
|
||||||
| --- | ---: | --- |
|
|
||||||
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Status polling interval used by box pages. |
|
```text
|
||||||
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of thumbnail jobs per worker pass. |
|
<WARPBOX_DATA_DIR>/uploads
|
||||||
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
|
<WARPBOX_DATA_DIR>/db
|
||||||
|
```
|
||||||
|
|
||||||
|
The admin account is bootstrapped from `WARPBOX_ADMIN_PASSWORD` when no admin
|
||||||
|
user exists. If the password is empty, admin login stays disabled unless an
|
||||||
|
admin user already exists in BadgerDB.
|
||||||
|
|
||||||
|
Primary environment variables:
|
||||||
|
|
||||||
|
- `WARPBOX_DATA_DIR`
|
||||||
|
- `WARPBOX_ADMIN_PASSWORD`
|
||||||
|
- `WARPBOX_ADMIN_USERNAME`
|
||||||
|
- `WARPBOX_ADMIN_EMAIL`
|
||||||
|
- `WARPBOX_ADMIN_ENABLED`
|
||||||
|
- `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE`
|
||||||
|
- `WARPBOX_ADMIN_COOKIE_SECURE`
|
||||||
|
- `WARPBOX_GUEST_UPLOADS_ENABLED`
|
||||||
|
- `WARPBOX_API_ENABLED`
|
||||||
|
- `WARPBOX_ZIP_DOWNLOADS_ENABLED`
|
||||||
|
- `WARPBOX_ONE_TIME_DOWNLOADS_ENABLED`
|
||||||
|
- `WARPBOX_RENEW_ON_ACCESS_ENABLED`
|
||||||
|
- `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED`
|
||||||
|
- `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS`
|
||||||
|
- `WARPBOX_MAX_GUEST_EXPIRY_SECONDS`
|
||||||
|
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES`
|
||||||
|
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES`
|
||||||
|
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES`
|
||||||
|
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES`
|
||||||
|
- `WARPBOX_SESSION_TTL_SECONDS`
|
||||||
|
- `WARPBOX_BOX_POLL_INTERVAL_MS`
|
||||||
|
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
|
||||||
|
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
|
||||||
|
|
||||||
The HTTP listen address is configured through the CLI flag:
|
The HTTP listen address is configured through the CLI flag:
|
||||||
|
|
||||||
@@ -106,10 +138,13 @@ go run ./cmd run --addr :8080
|
|||||||
cmd/main.go CLI setup
|
cmd/main.go CLI setup
|
||||||
lib/server/server.go Gin engine setup and worker startup
|
lib/server/server.go Gin engine setup and worker startup
|
||||||
lib/server/handlers.go HTTP handlers
|
lib/server/handlers.go HTTP handlers
|
||||||
|
lib/server/admin.go Admin handlers
|
||||||
lib/routing/routes.go Route table
|
lib/routing/routes.go Route table
|
||||||
lib/boxstore/store.go Box manifests, uploads, downloads, retention
|
lib/boxstore/store.go Box manifests, uploads, downloads, retention
|
||||||
lib/boxstore/thumbnails.go
|
lib/boxstore/thumbnails.go
|
||||||
Thumbnail scanning and generation
|
Thumbnail scanning and generation
|
||||||
|
lib/config/config.go Typed config and settings definitions
|
||||||
|
lib/metastore/ BadgerDB metadata store
|
||||||
lib/models/models.go Shared data structures
|
lib/models/models.go Shared data structures
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
31
go.mod
31
go.mod
@@ -1,44 +1,53 @@
|
|||||||
module warpbox
|
module warpbox
|
||||||
|
|
||||||
go 1.22
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dgraph-io/badger/v4 v4.8.0
|
||||||
github.com/gin-contrib/gzip v1.0.1
|
github.com/gin-contrib/gzip v1.0.1
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.9.1
|
||||||
|
golang.org/x/crypto v0.39.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/crypto v0.23.0 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/text v0.26.0 // indirect
|
||||||
golang.org/x/text v0.15.0 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
73
go.sum
73
go.sum
@@ -2,15 +2,24 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
|||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.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/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
|
||||||
|
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
|
||||||
|
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||||
|
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||||
|
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||||
|
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
|
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
|
||||||
@@ -19,6 +28,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
|||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -29,22 +43,23 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
|||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
@@ -58,17 +73,15 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -86,23 +99,29 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,13 +23,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
UploadRoot = "data/uploads"
|
|
||||||
manifestFile = ".warpbox.json"
|
manifestFile = ".warpbox.json"
|
||||||
|
|
||||||
OneTimeDownloadRetentionKey = "one-time"
|
OneTimeDownloadRetentionKey = "one-time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var manifestMu sync.Mutex
|
var (
|
||||||
|
uploadRoot = filepath.Join("data", "uploads")
|
||||||
|
manifestMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
var retentionOptions = []models.RetentionOption{
|
var retentionOptions = []models.RetentionOption{
|
||||||
{Key: "10s", Label: "10 seconds", Seconds: 10},
|
{Key: "10s", Label: "10 seconds", Seconds: 10},
|
||||||
@@ -58,8 +61,19 @@ func DefaultRetentionOption() models.RetentionOption {
|
|||||||
return retentionOptions[0]
|
return retentionOptions[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetUploadRoot(path string) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadRoot = filepath.Clean(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadRoot() string {
|
||||||
|
return uploadRoot
|
||||||
|
}
|
||||||
|
|
||||||
func BoxPath(boxID string) string {
|
func BoxPath(boxID string) string {
|
||||||
return filepath.Join(UploadRoot, boxID)
|
return filepath.Join(uploadRoot, boxID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ManifestPath(boxID string) string {
|
func ManifestPath(boxID string) string {
|
||||||
@@ -74,6 +88,71 @@ func DeleteBox(boxID string) error {
|
|||||||
return os.RemoveAll(BoxPath(boxID))
|
return os.RemoveAll(BoxPath(boxID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListBoxSummaries() ([]models.BoxSummary, error) {
|
||||||
|
entries, err := os.ReadDir(uploadRoot)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries := make([]models.BoxSummary, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() || !ValidBoxID(entry.Name()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := BoxSummary(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summaries = append(summaries, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(summaries, func(i int, j int) bool {
|
||||||
|
return summaries[i].CreatedAt.After(summaries[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BoxSummary(boxID string) (models.BoxSummary, error) {
|
||||||
|
files, err := ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return models.BoxSummary{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest models.BoxManifest
|
||||||
|
hasManifest := false
|
||||||
|
if readManifest, err := ReadManifest(boxID); err == nil {
|
||||||
|
manifest = readManifest
|
||||||
|
hasManifest = true
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range files {
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := models.BoxSummary{
|
||||||
|
ID: boxID,
|
||||||
|
FileCount: len(files),
|
||||||
|
TotalSize: totalSize,
|
||||||
|
TotalSizeLabel: helpers.FormatBytes(totalSize),
|
||||||
|
}
|
||||||
|
if hasManifest {
|
||||||
|
summary.CreatedAt = manifest.CreatedAt
|
||||||
|
summary.ExpiresAt = manifest.ExpiresAt
|
||||||
|
summary.Expired = IsExpired(manifest)
|
||||||
|
summary.OneTimeDownload = manifest.OneTimeDownload
|
||||||
|
summary.PasswordProtected = IsPasswordProtected(manifest)
|
||||||
|
} else if info, err := os.Stat(BoxPath(boxID)); err == nil {
|
||||||
|
summary.CreatedAt = info.ModTime().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ListFiles(boxID string) ([]models.BoxFile, error) {
|
func ListFiles(boxID string) ([]models.BoxFile, error) {
|
||||||
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
|
if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 {
|
||||||
files := make([]models.BoxFile, 0, len(manifest.Files))
|
files := make([]models.BoxFile, 0, len(manifest.Files))
|
||||||
@@ -240,6 +319,21 @@ func WriteManifest(boxID string, manifest models.BoxManifest) error {
|
|||||||
return writeManifestUnlocked(boxID, manifest)
|
return writeManifestUnlocked(boxID, manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
|
||||||
|
manifestMu.Lock()
|
||||||
|
defer manifestMu.Unlock()
|
||||||
|
|
||||||
|
manifest, err := readManifestUnlocked(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return manifest, err
|
||||||
|
}
|
||||||
|
if seconds <= 0 || manifest.OneTimeDownload || manifest.ExpiresAt.IsZero() {
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||||
|
return manifest, writeManifestUnlocked(boxID, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
|
func AddFileToZip(zipWriter *zip.Writer, boxID string, filename string) error {
|
||||||
source, err := os.Open(filepath.Join(BoxPath(boxID), filename))
|
source, err := os.Open(filepath.Join(BoxPath(boxID), filename))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
"image/jpeg"
|
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -66,7 +66,7 @@ func ThumbnailFilePath(boxID string, fileID string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func collectThumbnailTasks(batchSize int) []thumbnailTask {
|
func collectThumbnailTasks(batchSize int) []thumbnailTask {
|
||||||
entries, err := os.ReadDir(UploadRoot)
|
entries, err := os.ReadDir(uploadRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
523
lib/config/config.go
Normal file
523
lib/config/config.go
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Source string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceDefault Source = "default"
|
||||||
|
SourceEnv Source = "environment"
|
||||||
|
SourceDB Source = "db override"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminEnabledMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdminEnabledAuto AdminEnabledMode = "auto"
|
||||||
|
AdminEnabledTrue AdminEnabledMode = "true"
|
||||||
|
AdminEnabledFalse AdminEnabledMode = "false"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingGuestUploadsEnabled = "guest_uploads_enabled"
|
||||||
|
SettingAPIEnabled = "api_enabled"
|
||||||
|
SettingZipDownloadsEnabled = "zip_downloads_enabled"
|
||||||
|
SettingOneTimeDownloadsEnabled = "one_time_downloads_enabled"
|
||||||
|
SettingRenewOnAccessEnabled = "renew_on_access_enabled"
|
||||||
|
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
||||||
|
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
||||||
|
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
||||||
|
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
|
||||||
|
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
|
||||||
|
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
|
||||||
|
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
|
||||||
|
SettingSessionTTLSeconds = "session_ttl_seconds"
|
||||||
|
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
||||||
|
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||||
|
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||||
|
SettingDataDir = "data_dir"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingTypeBool SettingType = "bool"
|
||||||
|
SettingTypeInt64 SettingType = "int64"
|
||||||
|
SettingTypeInt SettingType = "int"
|
||||||
|
SettingTypeText SettingType = "text"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingDefinition struct {
|
||||||
|
Key string
|
||||||
|
EnvName string
|
||||||
|
Label string
|
||||||
|
Type SettingType
|
||||||
|
Editable bool
|
||||||
|
HardLimit bool
|
||||||
|
Minimum int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingRow struct {
|
||||||
|
Definition SettingDefinition
|
||||||
|
Value string
|
||||||
|
Source Source
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DataDir string
|
||||||
|
UploadsDir string
|
||||||
|
DBDir string
|
||||||
|
|
||||||
|
AdminPassword string
|
||||||
|
AdminUsername string
|
||||||
|
AdminEmail string
|
||||||
|
AdminEnabled AdminEnabledMode
|
||||||
|
AdminCookieSecure bool
|
||||||
|
AllowAdminSettingsOverride bool
|
||||||
|
|
||||||
|
GuestUploadsEnabled bool
|
||||||
|
APIEnabled bool
|
||||||
|
ZipDownloadsEnabled bool
|
||||||
|
OneTimeDownloadsEnabled bool
|
||||||
|
RenewOnAccessEnabled bool
|
||||||
|
RenewOnDownloadEnabled bool
|
||||||
|
|
||||||
|
DefaultGuestExpirySeconds int64
|
||||||
|
MaxGuestExpirySeconds int64
|
||||||
|
GlobalMaxFileSizeBytes int64
|
||||||
|
GlobalMaxBoxSizeBytes int64
|
||||||
|
DefaultUserMaxFileSizeBytes int64
|
||||||
|
DefaultUserMaxBoxSizeBytes int64
|
||||||
|
SessionTTLSeconds int64
|
||||||
|
BoxPollIntervalMS int
|
||||||
|
ThumbnailBatchSize int
|
||||||
|
ThumbnailIntervalSeconds int
|
||||||
|
|
||||||
|
sources map[string]Source
|
||||||
|
values map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var Definitions = []SettingDefinition{
|
||||||
|
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
|
||||||
|
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingOneTimeDownloadsEnabled, EnvName: "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", Label: "One-time downloads enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingRenewOnAccessEnabled, EnvName: "WARPBOX_RENEW_ON_ACCESS_ENABLED", Label: "Renew on access enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
|
||||||
|
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||||
|
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||||
|
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||||
|
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||||
|
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
||||||
|
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
DataDir: "./data",
|
||||||
|
AdminUsername: "admin",
|
||||||
|
AdminEnabled: AdminEnabledAuto,
|
||||||
|
AllowAdminSettingsOverride: true,
|
||||||
|
GuestUploadsEnabled: true,
|
||||||
|
APIEnabled: true,
|
||||||
|
ZipDownloadsEnabled: true,
|
||||||
|
OneTimeDownloadsEnabled: true,
|
||||||
|
DefaultGuestExpirySeconds: 10,
|
||||||
|
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||||
|
SessionTTLSeconds: 24 * 60 * 60,
|
||||||
|
BoxPollIntervalMS: 5000,
|
||||||
|
ThumbnailBatchSize: 10,
|
||||||
|
ThumbnailIntervalSeconds: 30,
|
||||||
|
sources: make(map[string]Source),
|
||||||
|
values: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.captureDefaults()
|
||||||
|
|
||||||
|
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_USERNAME", &cfg.AdminUsername); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||||
|
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||||
|
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||||
|
return nil, fmt.Errorf("WARPBOX_ADMIN_ENABLED must be auto, true, or false")
|
||||||
|
}
|
||||||
|
cfg.AdminEnabled = mode
|
||||||
|
}
|
||||||
|
if err := cfg.applyBoolEnv("", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", &cfg.AllowAdminSettingsOverride); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyBoolEnv("", "WARPBOX_ADMIN_COOKIE_SECURE", &cfg.AdminCookieSecure); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
envBools := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
target *bool
|
||||||
|
}{
|
||||||
|
{SettingGuestUploadsEnabled, "WARPBOX_GUEST_UPLOADS_ENABLED", &cfg.GuestUploadsEnabled},
|
||||||
|
{SettingAPIEnabled, "WARPBOX_API_ENABLED", &cfg.APIEnabled},
|
||||||
|
{SettingZipDownloadsEnabled, "WARPBOX_ZIP_DOWNLOADS_ENABLED", &cfg.ZipDownloadsEnabled},
|
||||||
|
{SettingOneTimeDownloadsEnabled, "WARPBOX_ONE_TIME_DOWNLOADS_ENABLED", &cfg.OneTimeDownloadsEnabled},
|
||||||
|
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||||
|
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||||
|
}
|
||||||
|
for _, item := range envBools {
|
||||||
|
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envInt64s := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
min int64
|
||||||
|
target *int64
|
||||||
|
}{
|
||||||
|
{SettingDefaultGuestExpirySecs, "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", 0, &cfg.DefaultGuestExpirySeconds},
|
||||||
|
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
||||||
|
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", 0, &cfg.GlobalMaxFileSizeBytes},
|
||||||
|
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", 0, &cfg.GlobalMaxBoxSizeBytes},
|
||||||
|
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", 0, &cfg.DefaultUserMaxFileSizeBytes},
|
||||||
|
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", 0, &cfg.DefaultUserMaxBoxSizeBytes},
|
||||||
|
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
||||||
|
}
|
||||||
|
for _, item := range envInt64s {
|
||||||
|
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envInts := []struct {
|
||||||
|
key string
|
||||||
|
name string
|
||||||
|
min int
|
||||||
|
target *int
|
||||||
|
}{
|
||||||
|
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
||||||
|
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
||||||
|
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
||||||
|
}
|
||||||
|
for _, item := range envInts {
|
||||||
|
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DataDir = filepath.Clean(cfg.DataDir)
|
||||||
|
if strings.TrimSpace(cfg.DataDir) == "" || cfg.DataDir == "." && strings.TrimSpace(os.Getenv("WARPBOX_DATA_DIR")) == "" {
|
||||||
|
cfg.DataDir = "data"
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername = strings.TrimSpace(cfg.AdminUsername); cfg.AdminUsername == "" {
|
||||||
|
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
||||||
|
}
|
||||||
|
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
||||||
|
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
||||||
|
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
||||||
|
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) EnsureDirectories() error {
|
||||||
|
for _, path := range []string{cfg.DataDir, cfg.UploadsDir, cfg.DBDir} {
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
||||||
|
if !cfg.AllowAdminSettingsOverride {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for key, value := range overrides {
|
||||||
|
if err := cfg.ApplyOverride(key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) ApplyOverride(key string, value string) error {
|
||||||
|
def, ok := Definition(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown setting %q", key)
|
||||||
|
}
|
||||||
|
if !def.Editable || def.HardLimit {
|
||||||
|
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch def.Type {
|
||||||
|
case SettingTypeBool:
|
||||||
|
parsed, err := parseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignBool(key, parsed, SourceDB)
|
||||||
|
case SettingTypeInt64:
|
||||||
|
parsed, err := parseInt64(value, def.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt64(key, parsed, SourceDB)
|
||||||
|
case SettingTypeInt:
|
||||||
|
parsed64, err := parseInt64(value, def.Minimum)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
|
}
|
||||||
|
cfg.assignInt(key, int(parsed64), SourceDB)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("setting %q is not runtime editable", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) SettingRows() []SettingRow {
|
||||||
|
rows := make([]SettingRow, 0, len(Definitions))
|
||||||
|
for _, def := range Definitions {
|
||||||
|
rows = append(rows, SettingRow{
|
||||||
|
Definition: def,
|
||||||
|
Value: cfg.values[def.Key],
|
||||||
|
Source: cfg.sourceFor(def.Key),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) Source(key string) Source {
|
||||||
|
return cfg.sourceFor(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
||||||
|
switch cfg.AdminEnabled {
|
||||||
|
case AdminEnabledFalse:
|
||||||
|
return false
|
||||||
|
case AdminEnabledTrue:
|
||||||
|
return hasAdminUser
|
||||||
|
default:
|
||||||
|
return hasAdminUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Definition(key string) (SettingDefinition, bool) {
|
||||||
|
for _, def := range Definitions {
|
||||||
|
if def.Key == key {
|
||||||
|
return def, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SettingDefinition{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func EditableDefinitions() []SettingDefinition {
|
||||||
|
defs := make([]SettingDefinition, 0, len(Definitions))
|
||||||
|
for _, def := range Definitions {
|
||||||
|
if def.Editable && !def.HardLimit {
|
||||||
|
defs = append(defs, def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) captureDefaults() {
|
||||||
|
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault)
|
||||||
|
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault)
|
||||||
|
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault)
|
||||||
|
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
|
||||||
|
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
|
||||||
|
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
||||||
|
raw := os.Getenv(name)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*target = raw
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, raw, SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyBoolEnv(key string, name string, target *bool) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, formatBool(parsed), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int64) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseInt64(raw, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) applyIntEnv(key string, name string, min int, target *int) error {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parsed, err := parseInt(raw, min)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
*target = parsed
|
||||||
|
if key != "" {
|
||||||
|
cfg.setValue(key, strconv.Itoa(parsed), SourceEnv)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingGuestUploadsEnabled:
|
||||||
|
cfg.GuestUploadsEnabled = value
|
||||||
|
case SettingAPIEnabled:
|
||||||
|
cfg.APIEnabled = value
|
||||||
|
case SettingZipDownloadsEnabled:
|
||||||
|
cfg.ZipDownloadsEnabled = value
|
||||||
|
case SettingOneTimeDownloadsEnabled:
|
||||||
|
cfg.OneTimeDownloadsEnabled = value
|
||||||
|
case SettingRenewOnAccessEnabled:
|
||||||
|
cfg.RenewOnAccessEnabled = value
|
||||||
|
case SettingRenewOnDownloadEnabled:
|
||||||
|
cfg.RenewOnDownloadEnabled = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, formatBool(value), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingDefaultGuestExpirySecs:
|
||||||
|
cfg.DefaultGuestExpirySeconds = value
|
||||||
|
case SettingMaxGuestExpirySecs:
|
||||||
|
cfg.MaxGuestExpirySeconds = value
|
||||||
|
case SettingDefaultUserMaxFileBytes:
|
||||||
|
cfg.DefaultUserMaxFileSizeBytes = value
|
||||||
|
case SettingDefaultUserMaxBoxBytes:
|
||||||
|
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||||
|
case SettingSessionTTLSeconds:
|
||||||
|
cfg.SessionTTLSeconds = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignInt(key string, value int, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingBoxPollIntervalMS:
|
||||||
|
cfg.BoxPollIntervalMS = value
|
||||||
|
case SettingThumbnailBatchSize:
|
||||||
|
cfg.ThumbnailBatchSize = value
|
||||||
|
case SettingThumbnailIntervalSeconds:
|
||||||
|
cfg.ThumbnailIntervalSeconds = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, strconv.Itoa(value), source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.values[key] = value
|
||||||
|
cfg.sources[key] = source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) sourceFor(key string) Source {
|
||||||
|
source, ok := cfg.sources[key]
|
||||||
|
if !ok {
|
||||||
|
return SourceDefault
|
||||||
|
}
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(value string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "1", "t", "true", "y", "yes", "on":
|
||||||
|
return true, nil
|
||||||
|
case "0", "f", "false", "n", "no", "off":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("must be a boolean")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64(value string, min int64) (int64, error) {
|
||||||
|
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("must be an integer")
|
||||||
|
}
|
||||||
|
if parsed < min {
|
||||||
|
return 0, fmt.Errorf("must be at least %d", min)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(value string, min int) (int, error) {
|
||||||
|
parsed64, err := parseInt64(value, int64(min))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if parsed64 > int64(^uint(0)>>1) {
|
||||||
|
return 0, fmt.Errorf("is too large")
|
||||||
|
}
|
||||||
|
return int(parsed64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBool(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
}
|
||||||
145
lib/config/config_test.go
Normal file
145
lib/config/config_test.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaults(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.UploadsDir != filepath.Join("data", "uploads") {
|
||||||
|
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
|
||||||
|
}
|
||||||
|
if cfg.DBDir != filepath.Join("data", "db") {
|
||||||
|
t.Fatalf("unexpected db dir: %s", cfg.DBDir)
|
||||||
|
}
|
||||||
|
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
|
||||||
|
t.Fatal("expected default guest/API/download toggles to be enabled")
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername != "admin" {
|
||||||
|
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
||||||
|
}
|
||||||
|
if cfg.AdminPassword != "" {
|
||||||
|
t.Fatal("expected default admin password to be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvironmentOverrides(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_DATA_DIR", "/tmp/warpbox-test")
|
||||||
|
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "false")
|
||||||
|
t.Setenv("WARPBOX_API_ENABLED", "false")
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||||
|
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
|
||||||
|
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.UploadsDir != filepath.Join("/tmp/warpbox-test", "uploads") {
|
||||||
|
t.Fatalf("unexpected uploads dir: %s", cfg.UploadsDir)
|
||||||
|
}
|
||||||
|
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
|
||||||
|
t.Fatal("expected boolean environment overrides to be applied")
|
||||||
|
}
|
||||||
|
if cfg.GlobalMaxFileSizeBytes != 100 {
|
||||||
|
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if cfg.BoxPollIntervalMS != 2000 {
|
||||||
|
t.Fatalf("unexpected poll interval: %d", cfg.BoxPollIntervalMS)
|
||||||
|
}
|
||||||
|
if cfg.AdminUsername != "root" {
|
||||||
|
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
||||||
|
}
|
||||||
|
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
||||||
|
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidEnvironmentValues(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_SESSION_TTL_SECONDS", "1")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("expected invalid session ttl to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_GUEST_UPLOADS_ENABLED", "maybe")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("expected invalid boolean to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsOverridePrecedence(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_API_ENABLED", "true")
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(map[string]string{SettingAPIEnabled: "false"}); err != nil {
|
||||||
|
t.Fatalf("ApplyOverrides returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.APIEnabled {
|
||||||
|
t.Fatal("expected DB override to beat environment value")
|
||||||
|
}
|
||||||
|
if cfg.Source(SettingAPIEnabled) != SourceDB {
|
||||||
|
t.Fatalf("expected DB source, got %s", cfg.Source(SettingAPIEnabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsOverrideValidation(t *testing.T) {
|
||||||
|
clearConfigEnv(t)
|
||||||
|
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil {
|
||||||
|
t.Fatal("expected negative expiry override to fail")
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
|
||||||
|
t.Fatal("expected hard limit override to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearConfigEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range []string{
|
||||||
|
"WARPBOX_DATA_DIR",
|
||||||
|
"WARPBOX_ADMIN_PASSWORD",
|
||||||
|
"WARPBOX_ADMIN_USERNAME",
|
||||||
|
"WARPBOX_ADMIN_EMAIL",
|
||||||
|
"WARPBOX_ADMIN_ENABLED",
|
||||||
|
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||||
|
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||||
|
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||||
|
"WARPBOX_API_ENABLED",
|
||||||
|
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||||
|
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||||
|
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_SESSION_TTL_SECONDS",
|
||||||
|
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||||
|
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||||
|
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||||
|
} {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
71
lib/metastore/bootstrap.go
Normal file
71
lib/metastore/bootstrap.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BootstrapAdmin(cfg *config.Config, store *Store) (BootstrapResult, error) {
|
||||||
|
adminTag, err := store.EnsureAdminTag()
|
||||||
|
if err != nil {
|
||||||
|
return BootstrapResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminUser *User
|
||||||
|
user, ok, err := store.GetUserByUsername(cfg.AdminUsername)
|
||||||
|
if err != nil {
|
||||||
|
return BootstrapResult{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if !hasString(user.TagIDs, adminTag.ID) {
|
||||||
|
user.TagIDs = append(user.TagIDs, adminTag.ID)
|
||||||
|
if err := store.UpdateUser(user); err != nil {
|
||||||
|
return BootstrapResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adminUser = &user
|
||||||
|
} else if strings.TrimSpace(cfg.AdminPassword) != "" {
|
||||||
|
created, err := store.CreateUserWithPassword(cfg.AdminUsername, cfg.AdminEmail, cfg.AdminPassword, []string{adminTag.ID})
|
||||||
|
if err != nil {
|
||||||
|
return BootstrapResult{}, err
|
||||||
|
}
|
||||||
|
adminUser = &created
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAdminUser, err := store.HasAdminUser(adminTag.ID)
|
||||||
|
if err != nil {
|
||||||
|
return BootstrapResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return BootstrapResult{
|
||||||
|
AdminTag: adminTag,
|
||||||
|
AdminUser: adminUser,
|
||||||
|
AdminLoginEnabled: cfg.AdminLoginEnabled(hasAdminUser),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) HasAdminUser(adminTagID string) (bool, error) {
|
||||||
|
users, err := store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hasString(user.TagIDs, adminTagID) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasString(values []string, target string) bool {
|
||||||
|
for _, value := range values {
|
||||||
|
if value == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
222
lib/metastore/metastore_test.go
Normal file
222
lib/metastore/metastore_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenClose(t *testing.T) {
|
||||||
|
store, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Close(); err != nil {
|
||||||
|
t.Fatalf("Close returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapAdminFromPassword(t *testing.T) {
|
||||||
|
clearMetastoreConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret-pass")
|
||||||
|
t.Setenv("WARPBOX_ADMIN_EMAIL", "admin@example.test")
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
store := openTestStore(t)
|
||||||
|
|
||||||
|
result, err := BootstrapAdmin(cfg, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BootstrapAdmin returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !result.AdminLoginEnabled {
|
||||||
|
t.Fatal("expected admin login to be enabled")
|
||||||
|
}
|
||||||
|
if !result.AdminTag.Protected {
|
||||||
|
t.Fatal("expected admin tag to be protected")
|
||||||
|
}
|
||||||
|
if result.AdminUser == nil {
|
||||||
|
t.Fatal("expected bootstrap admin user")
|
||||||
|
}
|
||||||
|
if !hasString(result.AdminUser.TagIDs, result.AdminTag.ID) {
|
||||||
|
t.Fatal("expected bootstrap admin to have admin tag")
|
||||||
|
}
|
||||||
|
if !VerifyPassword(result.AdminUser.PasswordHash, "secret-pass") {
|
||||||
|
t.Fatal("expected bootstrap admin password to verify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapAdminDisabledWithoutPassword(t *testing.T) {
|
||||||
|
clearMetastoreConfigEnv(t)
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
store := openTestStore(t)
|
||||||
|
|
||||||
|
result, err := BootstrapAdmin(cfg, store)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BootstrapAdmin returned error: %v", err)
|
||||||
|
}
|
||||||
|
if result.AdminLoginEnabled {
|
||||||
|
t.Fatal("expected admin login to be disabled without password or existing admin")
|
||||||
|
}
|
||||||
|
if !result.AdminTag.Protected {
|
||||||
|
t.Fatal("expected admin tag to still be created")
|
||||||
|
}
|
||||||
|
users, err := store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListUsers returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(users) != 0 {
|
||||||
|
t.Fatalf("expected no users, got %d", len(users))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDuplicateUsersAndTags(t *testing.T) {
|
||||||
|
store := openTestStore(t)
|
||||||
|
|
||||||
|
if _, err := store.CreateUserWithPassword("alex", "alex@example.test", "secret", nil); err != nil {
|
||||||
|
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.CreateUserWithPassword("Alex", "other@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
|
||||||
|
t.Fatalf("expected duplicate username error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.CreateUserWithPassword("other", "alex@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
|
||||||
|
t.Fatalf("expected duplicate email error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := Tag{Name: "staff"}
|
||||||
|
if err := store.CreateTag(&tag); err != nil {
|
||||||
|
t.Fatalf("CreateTag returned error: %v", err)
|
||||||
|
}
|
||||||
|
duplicate := Tag{Name: "Staff"}
|
||||||
|
if err := store.CreateTag(&duplicate); !errors.Is(err, ErrDuplicate) {
|
||||||
|
t.Fatalf("expected duplicate tag error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPermissionResolutionAndGlobalCaps(t *testing.T) {
|
||||||
|
clearMetastoreConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "50")
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||||
|
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "1000")
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
tagFileLimit := int64(80)
|
||||||
|
tagBoxLimit := int64(2000)
|
||||||
|
userFileLimit := int64(60)
|
||||||
|
user := User{MaxFileSizeBytes: &userFileLimit}
|
||||||
|
tags := []Tag{
|
||||||
|
{
|
||||||
|
Permissions: TagPermissions{
|
||||||
|
UploadAllowed: true,
|
||||||
|
AllowedExpirySeconds: []int64{3600, 600},
|
||||||
|
MaxFileSizeBytes: &tagFileLimit,
|
||||||
|
MaxBoxSizeBytes: &tagBoxLimit,
|
||||||
|
ZipDownloadAllowed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
perms := ResolveUserPermissions(cfg, user, tags)
|
||||||
|
if !perms.UploadAllowed || !perms.ZipDownloadAllowed {
|
||||||
|
t.Fatal("expected tag booleans to grant permissions")
|
||||||
|
}
|
||||||
|
if perms.MaxFileSizeBytes != 80 {
|
||||||
|
t.Fatalf("expected tag limit to beat user/default limit, got %d", perms.MaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if perms.MaxBoxSizeBytes != 1000 {
|
||||||
|
t.Fatalf("expected global max box cap, got %d", perms.MaxBoxSizeBytes)
|
||||||
|
}
|
||||||
|
if len(perms.AllowedExpirySeconds) != 2 || perms.AllowedExpirySeconds[0] != 600 || perms.AllowedExpirySeconds[1] != 3600 {
|
||||||
|
t.Fatalf("unexpected expiry durations: %#v", perms.AllowedExpirySeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsStorageAndPrecedence(t *testing.T) {
|
||||||
|
clearMetastoreConfigEnv(t)
|
||||||
|
t.Setenv("WARPBOX_API_ENABLED", "true")
|
||||||
|
|
||||||
|
store := openTestStore(t)
|
||||||
|
if err := store.SetSetting(config.SettingAPIEnabled, "false"); err != nil {
|
||||||
|
t.Fatalf("SetSetting returned error: %v", err)
|
||||||
|
}
|
||||||
|
overrides, err := store.ListSettings()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSettings returned error: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load returned error: %v", err)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||||
|
t.Fatalf("ApplyOverrides returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.APIEnabled {
|
||||||
|
t.Fatal("expected stored DB override to beat env")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionExpiry(t *testing.T) {
|
||||||
|
store := openTestStore(t)
|
||||||
|
session, err := store.CreateSession("user-id", time.Millisecond)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession returned error: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
if _, ok, err := store.GetSession(session.Token); err != nil || ok {
|
||||||
|
t.Fatalf("expected expired session to be invalid, ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
store, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open returned error: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = store.Close()
|
||||||
|
})
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearMetastoreConfigEnv(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
for _, name := range []string{
|
||||||
|
"WARPBOX_DATA_DIR",
|
||||||
|
"WARPBOX_ADMIN_PASSWORD",
|
||||||
|
"WARPBOX_ADMIN_USERNAME",
|
||||||
|
"WARPBOX_ADMIN_EMAIL",
|
||||||
|
"WARPBOX_ADMIN_ENABLED",
|
||||||
|
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||||
|
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||||
|
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||||
|
"WARPBOX_API_ENABLED",
|
||||||
|
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||||
|
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||||
|
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||||
|
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||||
|
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||||
|
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||||
|
"WARPBOX_SESSION_TTL_SECONDS",
|
||||||
|
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||||
|
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||||
|
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||||
|
} {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
75
lib/metastore/models.go
Normal file
75
lib/metastore/models.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const AdminTagName = "admin"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
PasswordHash string `json:"password_hash"`
|
||||||
|
TagIDs []string `json:"tag_ids"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
||||||
|
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
||||||
|
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
Permissions TagPermissions `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagPermissions struct {
|
||||||
|
UploadAllowed bool `json:"upload_allowed"`
|
||||||
|
AllowedExpirySeconds []int64 `json:"allowed_expiry_seconds,omitempty"`
|
||||||
|
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
||||||
|
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
||||||
|
OneTimeDownloadAllowed bool `json:"one_time_download_allowed"`
|
||||||
|
ZipDownloadAllowed bool `json:"zip_download_allowed"`
|
||||||
|
RenewableAllowed bool `json:"renewable_allowed"`
|
||||||
|
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
|
||||||
|
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
|
||||||
|
AdminAccess bool `json:"admin_access"`
|
||||||
|
AdminUsersManage bool `json:"admin_users_manage"`
|
||||||
|
AdminSettingsManage bool `json:"admin_settings_manage"`
|
||||||
|
AdminBoxesView bool `json:"admin_boxes_view"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EffectivePermissions struct {
|
||||||
|
UploadAllowed bool
|
||||||
|
AllowedExpirySeconds []int64
|
||||||
|
MaxFileSizeBytes int64
|
||||||
|
MaxBoxSizeBytes int64
|
||||||
|
MaxExpirySeconds int64
|
||||||
|
OneTimeDownloadAllowed bool
|
||||||
|
ZipDownloadAllowed bool
|
||||||
|
RenewableAllowed bool
|
||||||
|
RenewOnAccessSeconds int64
|
||||||
|
RenewOnDownloadSeconds int64
|
||||||
|
AdminAccess bool
|
||||||
|
AdminUsersManage bool
|
||||||
|
AdminSettingsManage bool
|
||||||
|
AdminBoxesView bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type BootstrapResult struct {
|
||||||
|
AdminTag Tag
|
||||||
|
AdminUser *User
|
||||||
|
AdminLoginEnabled bool
|
||||||
|
}
|
||||||
141
lib/metastore/permissions.go
Normal file
141
lib/metastore/permissions.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"warpbox/lib/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) EffectivePermissions {
|
||||||
|
perms := EffectivePermissions{
|
||||||
|
MaxFileSizeBytes: cfg.DefaultUserMaxFileSizeBytes,
|
||||||
|
MaxBoxSizeBytes: cfg.DefaultUserMaxBoxSizeBytes,
|
||||||
|
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
|
||||||
|
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
expirySet := make(map[int64]bool)
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagPerms := tag.Permissions
|
||||||
|
perms.UploadAllowed = perms.UploadAllowed || tagPerms.UploadAllowed
|
||||||
|
perms.OneTimeDownloadAllowed = perms.OneTimeDownloadAllowed || tagPerms.OneTimeDownloadAllowed
|
||||||
|
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
|
||||||
|
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
|
||||||
|
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
|
||||||
|
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
|
||||||
|
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
|
||||||
|
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
|
||||||
|
perms.RenewOnAccessSeconds = maxInt64(perms.RenewOnAccessSeconds, tagPerms.RenewOnAccessSeconds)
|
||||||
|
perms.RenewOnDownloadSeconds = maxInt64(perms.RenewOnDownloadSeconds, tagPerms.RenewOnDownloadSeconds)
|
||||||
|
if tagPerms.MaxFileSizeBytes != nil {
|
||||||
|
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *tagPerms.MaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if tagPerms.MaxBoxSizeBytes != nil {
|
||||||
|
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *tagPerms.MaxBoxSizeBytes)
|
||||||
|
}
|
||||||
|
for _, seconds := range tagPerms.AllowedExpirySeconds {
|
||||||
|
if seconds >= 0 {
|
||||||
|
expirySet[seconds] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.MaxFileSizeBytes != nil {
|
||||||
|
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *user.MaxFileSizeBytes)
|
||||||
|
}
|
||||||
|
if user.MaxBoxSizeBytes != nil {
|
||||||
|
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *user.MaxBoxSizeBytes)
|
||||||
|
}
|
||||||
|
if user.MaxExpirySeconds != nil {
|
||||||
|
perms.MaxExpirySeconds = *user.MaxExpirySeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
|
||||||
|
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
|
||||||
|
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
|
||||||
|
if !cfg.ZipDownloadsEnabled {
|
||||||
|
perms.ZipDownloadAllowed = false
|
||||||
|
}
|
||||||
|
if !cfg.OneTimeDownloadsEnabled {
|
||||||
|
perms.OneTimeDownloadAllowed = false
|
||||||
|
}
|
||||||
|
return perms
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveGuestPermissions(cfg *config.Config) EffectivePermissions {
|
||||||
|
return EffectivePermissions{
|
||||||
|
UploadAllowed: cfg.GuestUploadsEnabled,
|
||||||
|
AllowedExpirySeconds: guestExpirySeconds(cfg),
|
||||||
|
MaxFileSizeBytes: cfg.GlobalMaxFileSizeBytes,
|
||||||
|
MaxBoxSizeBytes: cfg.GlobalMaxBoxSizeBytes,
|
||||||
|
MaxExpirySeconds: cfg.MaxGuestExpirySeconds,
|
||||||
|
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
|
||||||
|
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
|
||||||
|
RenewableAllowed: cfg.RenewOnAccessEnabled || cfg.RenewOnDownloadEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func morePermissiveLimit(current int64, candidate int64) int64 {
|
||||||
|
if current == 0 || candidate == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if candidate > current {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func capLimit(value int64, globalMax int64) int64 {
|
||||||
|
if globalMax == 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if value == 0 || value > globalMax {
|
||||||
|
return globalMax
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedExpirySet(expirySet map[int64]bool) []int64 {
|
||||||
|
values := make([]int64, 0, len(expirySet))
|
||||||
|
for value := range expirySet {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
sort.Slice(values, func(i int, j int) bool {
|
||||||
|
return values[i] < values[j]
|
||||||
|
})
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func guestExpirySeconds(cfg *config.Config) []int64 {
|
||||||
|
values := []int64{}
|
||||||
|
if cfg.DefaultGuestExpirySeconds >= 0 {
|
||||||
|
values = append(values, cfg.DefaultGuestExpirySeconds)
|
||||||
|
}
|
||||||
|
if cfg.MaxGuestExpirySeconds > 0 && cfg.MaxGuestExpirySeconds != cfg.DefaultGuestExpirySeconds {
|
||||||
|
values = append(values, cfg.MaxGuestExpirySeconds)
|
||||||
|
}
|
||||||
|
return uniqueInt64s(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueInt64s(values []int64) []int64 {
|
||||||
|
seen := make(map[int64]bool, len(values))
|
||||||
|
out := make([]int64, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i int, j int) bool {
|
||||||
|
return out[i] < out[j]
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt64(a int64, b int64) int64 {
|
||||||
|
if b > a {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
74
lib/metastore/sessions.go
Normal file
74
lib/metastore/sessions.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) CreateSession(userID string, ttl time.Duration) (Session, error) {
|
||||||
|
userID = strings.TrimSpace(userID)
|
||||||
|
if userID == "" {
|
||||||
|
return Session{}, fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
return Session{}, fmt.Errorf("%w: session ttl must be positive", ErrInvalid)
|
||||||
|
}
|
||||||
|
token, err := helpers.RandomHexID(32)
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
session := Session{
|
||||||
|
Token: token,
|
||||||
|
UserID: userID,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now.Add(ttl),
|
||||||
|
}
|
||||||
|
err = store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
return putJSON(txn, sessionKey(token), session)
|
||||||
|
})
|
||||||
|
return session, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetSession(token string) (Session, bool, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return Session{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var session Session
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
return getJSON(txn, sessionKey(token), &session)
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Session{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, false, err
|
||||||
|
}
|
||||||
|
if time.Now().UTC().After(session.ExpiresAt) {
|
||||||
|
_ = store.DeleteSession(token)
|
||||||
|
return Session{}, false, nil
|
||||||
|
}
|
||||||
|
return session, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) DeleteSession(token string) error {
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
err := txn.Delete(sessionKey(token))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionKey(token string) []byte {
|
||||||
|
return []byte("session/" + strings.TrimSpace(token))
|
||||||
|
}
|
||||||
379
lib/metastore/store.go
Normal file
379
lib/metastore/store.go
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
ErrDuplicate = errors.New("duplicate")
|
||||||
|
ErrInvalid = errors.New("invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *badger.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
opts := badger.DefaultOptions(path).WithLogger(nil)
|
||||||
|
db, err := badger.Open(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) Close() error {
|
||||||
|
if store == nil || store.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return store.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) SetSetting(name string, value string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("%w: setting name cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
return txn.Set(settingKey(name), []byte(value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) DeleteSetting(name string) error {
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
return txn.Delete(settingKey(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetSetting(name string) (string, bool, error) {
|
||||||
|
var value string
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get(settingKey(name))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(data []byte) error {
|
||||||
|
value = string(data)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return value, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListSettings() (map[string]string, error) {
|
||||||
|
settings := make(map[string]string)
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = []byte("setting/")
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
item := it.Item()
|
||||||
|
name := strings.TrimPrefix(string(item.Key()), "setting/")
|
||||||
|
if err := item.Value(func(data []byte) error {
|
||||||
|
settings[name] = string(data)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
if strings.TrimSpace(password) == "" {
|
||||||
|
return "", fmt.Errorf("%w: password cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyPassword(hash string, password string) bool {
|
||||||
|
if hash == "" || password == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateUserWithPassword(username string, email string, password string, tagIDs []string) (User, error) {
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
user := User{
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: hash,
|
||||||
|
TagIDs: uniqueStrings(tagIDs),
|
||||||
|
}
|
||||||
|
if err := store.CreateUser(&user); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateUser(user *User) error {
|
||||||
|
if user == nil {
|
||||||
|
return fmt.Errorf("%w: user cannot be nil", ErrInvalid)
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(user.Username)
|
||||||
|
if username == "" {
|
||||||
|
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
email := strings.TrimSpace(user.Email)
|
||||||
|
if user.PasswordHash == "" {
|
||||||
|
return fmt.Errorf("%w: password hash cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if user.ID == "" {
|
||||||
|
id, err := helpers.RandomHexID(16)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.ID = id
|
||||||
|
}
|
||||||
|
user.Username = username
|
||||||
|
user.Email = email
|
||||||
|
user.TagIDs = uniqueStrings(user.TagIDs)
|
||||||
|
user.CreatedAt = now
|
||||||
|
user.UpdatedAt = now
|
||||||
|
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
if exists, err := keyExists(txn, usernameKey(username)); err != nil || exists {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: username already exists", ErrDuplicate)
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
if exists, err := keyExists(txn, emailKey(email)); err != nil || exists {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: email already exists", ErrDuplicate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := putJSON(txn, userKey(user.ID), user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := txn.Set(usernameKey(username), []byte(user.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
return txn.Set(emailKey(email), []byte(user.ID))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UpdateUser(user User) error {
|
||||||
|
if strings.TrimSpace(user.ID) == "" {
|
||||||
|
return fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
user.Username = strings.TrimSpace(user.Username)
|
||||||
|
user.Email = strings.TrimSpace(user.Email)
|
||||||
|
if user.Username == "" {
|
||||||
|
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
user.TagIDs = uniqueStrings(user.TagIDs)
|
||||||
|
user.UpdatedAt = time.Now().UTC()
|
||||||
|
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
var existing User
|
||||||
|
if err := getJSON(txn, userKey(user.ID), &existing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldUsername := normalizeIndex(existing.Username)
|
||||||
|
newUsername := normalizeIndex(user.Username)
|
||||||
|
if oldUsername != newUsername {
|
||||||
|
if exists, err := keyExists(txn, usernameKey(user.Username)); err != nil || exists {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: username already exists", ErrDuplicate)
|
||||||
|
}
|
||||||
|
if err := txn.Delete(usernameKey(existing.Username)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := txn.Set(usernameKey(user.Username), []byte(user.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldEmail := normalizeIndex(existing.Email)
|
||||||
|
newEmail := normalizeIndex(user.Email)
|
||||||
|
if oldEmail != newEmail {
|
||||||
|
if newEmail != "" {
|
||||||
|
if exists, err := keyExists(txn, emailKey(user.Email)); err != nil || exists {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: email already exists", ErrDuplicate)
|
||||||
|
}
|
||||||
|
if err := txn.Set(emailKey(user.Email), []byte(user.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldEmail != "" {
|
||||||
|
if err := txn.Delete(emailKey(existing.Email)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return putJSON(txn, userKey(user.ID), user)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetUser(id string) (User, bool, error) {
|
||||||
|
var user User
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
return getJSON(txn, userKey(id), &user)
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return User{}, false, nil
|
||||||
|
}
|
||||||
|
return user, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetUserByUsername(username string) (User, bool, error) {
|
||||||
|
return store.getUserByIndex(usernameKey(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetUserByEmail(email string) (User, bool, error) {
|
||||||
|
return store.getUserByIndex(emailKey(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListUsers() ([]User, error) {
|
||||||
|
users := []User{}
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = []byte("user/")
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
var user User
|
||||||
|
if err := it.Item().Value(func(data []byte) error {
|
||||||
|
return json.Unmarshal(data, &user)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
|
||||||
|
var id string
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get(key)
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(data []byte) error {
|
||||||
|
id = string(data)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return User{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, false, err
|
||||||
|
}
|
||||||
|
return store.GetUser(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func putJSON(txn *badger.Txn, key []byte, value any) error {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txn.Set(key, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJSON(txn *badger.Txn, key []byte, value any) error {
|
||||||
|
item, err := txn.Get(key)
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(data []byte) error {
|
||||||
|
return json.Unmarshal(data, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyExists(txn *badger.Txn, key []byte) (bool, error) {
|
||||||
|
_, err := txn.Get(key)
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingKey(name string) []byte {
|
||||||
|
return []byte("setting/" + strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func userKey(id string) []byte {
|
||||||
|
return []byte("user/" + strings.TrimSpace(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func usernameKey(username string) []byte {
|
||||||
|
return []byte("user_by_name/" + normalizeIndex(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailKey(email string) []byte {
|
||||||
|
return []byte("user_by_email/" + normalizeIndex(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeIndex(value string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := make(map[string]bool, len(values))
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || seen[value] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[value] = true
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
220
lib/metastore/tags.go
Normal file
220
lib/metastore/tags.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package metastore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
|
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AdminPermissions() TagPermissions {
|
||||||
|
unlimited := int64(0)
|
||||||
|
return TagPermissions{
|
||||||
|
UploadAllowed: true,
|
||||||
|
MaxFileSizeBytes: &unlimited,
|
||||||
|
MaxBoxSizeBytes: &unlimited,
|
||||||
|
OneTimeDownloadAllowed: true,
|
||||||
|
ZipDownloadAllowed: true,
|
||||||
|
RenewableAllowed: true,
|
||||||
|
AdminAccess: true,
|
||||||
|
AdminUsersManage: true,
|
||||||
|
AdminSettingsManage: true,
|
||||||
|
AdminBoxesView: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) EnsureAdminTag() (Tag, error) {
|
||||||
|
tag, ok, err := store.GetTagByName(AdminTagName)
|
||||||
|
if err != nil {
|
||||||
|
return Tag{}, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
tag.Protected = true
|
||||||
|
tag.Permissions = AdminPermissions()
|
||||||
|
tag.Description = "Built-in administrator permissions"
|
||||||
|
if err := store.UpdateTag(tag); err != nil {
|
||||||
|
return Tag{}, err
|
||||||
|
}
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tag = Tag{
|
||||||
|
Name: AdminTagName,
|
||||||
|
Description: "Built-in administrator permissions",
|
||||||
|
Protected: true,
|
||||||
|
Permissions: AdminPermissions(),
|
||||||
|
}
|
||||||
|
if err := store.CreateTag(&tag); err != nil {
|
||||||
|
return Tag{}, err
|
||||||
|
}
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateTag(tag *Tag) error {
|
||||||
|
if tag == nil {
|
||||||
|
return fmt.Errorf("%w: tag cannot be nil", ErrInvalid)
|
||||||
|
}
|
||||||
|
tag.Name = strings.TrimSpace(tag.Name)
|
||||||
|
tag.Description = strings.TrimSpace(tag.Description)
|
||||||
|
if tag.Name == "" {
|
||||||
|
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if tag.ID == "" {
|
||||||
|
id, err := helpers.RandomHexID(16)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tag.ID = id
|
||||||
|
}
|
||||||
|
tag.CreatedAt = now
|
||||||
|
tag.UpdatedAt = now
|
||||||
|
normalizeTagPermissions(&tag.Permissions)
|
||||||
|
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
|
||||||
|
}
|
||||||
|
if err := putJSON(txn, tagKey(tag.ID), tag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return txn.Set(tagNameKey(tag.Name), []byte(tag.ID))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UpdateTag(tag Tag) error {
|
||||||
|
tag.Name = strings.TrimSpace(tag.Name)
|
||||||
|
tag.Description = strings.TrimSpace(tag.Description)
|
||||||
|
if tag.ID == "" {
|
||||||
|
return fmt.Errorf("%w: tag id cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
if tag.Name == "" {
|
||||||
|
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
|
||||||
|
}
|
||||||
|
tag.UpdatedAt = time.Now().UTC()
|
||||||
|
normalizeTagPermissions(&tag.Permissions)
|
||||||
|
|
||||||
|
return store.db.Update(func(txn *badger.Txn) error {
|
||||||
|
var existing Tag
|
||||||
|
if err := getJSON(txn, tagKey(tag.ID), &existing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if normalizeIndex(existing.Name) != normalizeIndex(tag.Name) {
|
||||||
|
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
|
||||||
|
}
|
||||||
|
if err := txn.Delete(tagNameKey(existing.Name)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := txn.Set(tagNameKey(tag.Name), []byte(tag.ID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existing.Protected {
|
||||||
|
tag.Protected = true
|
||||||
|
}
|
||||||
|
if tag.Name == AdminTagName {
|
||||||
|
tag.Protected = true
|
||||||
|
tag.Permissions = AdminPermissions()
|
||||||
|
}
|
||||||
|
return putJSON(txn, tagKey(tag.ID), tag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetTag(id string) (Tag, bool, error) {
|
||||||
|
var tag Tag
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
return getJSON(txn, tagKey(id), &tag)
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Tag{}, false, nil
|
||||||
|
}
|
||||||
|
return tag, err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetTagByName(name string) (Tag, bool, error) {
|
||||||
|
var id string
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
item, err := txn.Get(tagNameKey(name))
|
||||||
|
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return item.Value(func(data []byte) error {
|
||||||
|
id = string(data)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return Tag{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Tag{}, false, err
|
||||||
|
}
|
||||||
|
return store.GetTag(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListTags() ([]Tag, error) {
|
||||||
|
tags := []Tag{}
|
||||||
|
err := store.db.View(func(txn *badger.Txn) error {
|
||||||
|
opts := badger.DefaultIteratorOptions
|
||||||
|
opts.Prefix = []byte("tag/")
|
||||||
|
it := txn.NewIterator(opts)
|
||||||
|
defer it.Close()
|
||||||
|
|
||||||
|
for it.Rewind(); it.Valid(); it.Next() {
|
||||||
|
var tag Tag
|
||||||
|
if err := it.Item().Value(func(data []byte) error {
|
||||||
|
return json.Unmarshal(data, &tag)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return tags, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) TagsByID(ids []string) ([]Tag, error) {
|
||||||
|
tags := make([]Tag, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
tag, ok, err := store.GetTag(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTagPermissions(perms *TagPermissions) {
|
||||||
|
if perms == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perms.AllowedExpirySeconds = uniqueInt64s(perms.AllowedExpirySeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagKey(id string) []byte {
|
||||||
|
return []byte("tag/" + strings.TrimSpace(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagNameKey(name string) []byte {
|
||||||
|
return []byte("tag_by_name/" + normalizeIndex(name))
|
||||||
|
}
|
||||||
@@ -54,6 +54,18 @@ type BoxManifest struct {
|
|||||||
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BoxSummary struct {
|
||||||
|
ID string
|
||||||
|
FileCount int
|
||||||
|
TotalSize int64
|
||||||
|
TotalSizeLabel string
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Expired bool
|
||||||
|
OneTimeDownload bool
|
||||||
|
PasswordProtected bool
|
||||||
|
}
|
||||||
|
|
||||||
type CreateBoxRequest struct {
|
type CreateBoxRequest struct {
|
||||||
Files []CreateBoxFileRequest `json:"files"`
|
Files []CreateBoxFileRequest `json:"files"`
|
||||||
RetentionKey string `json:"retention_key"`
|
RetentionKey string `json:"retention_key"`
|
||||||
|
|||||||
562
lib/server/admin.go
Normal file
562
lib/server/admin.go
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/boxstore"
|
||||||
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/helpers"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
|
)
|
||||||
|
|
||||||
|
const adminSessionCookie = "warpbox_admin_session"
|
||||||
|
|
||||||
|
type adminUserRow struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Tags string
|
||||||
|
CreatedAt string
|
||||||
|
Disabled bool
|
||||||
|
IsCurrent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminTagRow struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Protected bool
|
||||||
|
AdminAccess bool
|
||||||
|
UploadAllowed bool
|
||||||
|
ZipDownloadAllowed bool
|
||||||
|
OneTimeDownloadAllowed bool
|
||||||
|
RenewableAllowed bool
|
||||||
|
MaxFileSizeBytes string
|
||||||
|
MaxBoxSizeBytes string
|
||||||
|
AllowedExpirySeconds string
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminBoxRow struct {
|
||||||
|
ID string
|
||||||
|
FileCount int
|
||||||
|
TotalSizeLabel string
|
||||||
|
CreatedAt string
|
||||||
|
ExpiresAt string
|
||||||
|
Expired bool
|
||||||
|
OneTimeDownload bool
|
||||||
|
PasswordProtected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||||
|
admin := router.Group("/admin")
|
||||||
|
admin.GET("/login", app.handleAdminLogin)
|
||||||
|
admin.POST("/login", app.handleAdminLoginPost)
|
||||||
|
|
||||||
|
protected := admin.Group("")
|
||||||
|
protected.Use(app.requireAdminSession)
|
||||||
|
protected.POST("/logout", app.handleAdminLogout)
|
||||||
|
protected.GET("", app.handleAdminDashboard)
|
||||||
|
protected.GET("/", app.handleAdminDashboard)
|
||||||
|
protected.GET("/boxes", app.handleAdminBoxes)
|
||||||
|
protected.GET("/users", app.handleAdminUsers)
|
||||||
|
protected.POST("/users", app.handleAdminUsersPost)
|
||||||
|
protected.GET("/tags", app.handleAdminTags)
|
||||||
|
protected.POST("/tags", app.handleAdminTagsPost)
|
||||||
|
protected.GET("/settings", app.handleAdminSettings)
|
||||||
|
protected.POST("/settings", app.handleAdminSettingsPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLogin(ctx *gin.Context) {
|
||||||
|
if app.isAdminSessionValid(ctx) {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderAdminLogin(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
||||||
|
if !app.adminLoginEnabled {
|
||||||
|
app.renderAdminLogin(ctx, "Administrator login is disabled.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
|
password := ctx.PostForm("password")
|
||||||
|
user, ok, err := app.store.GetUserByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
|
||||||
|
app.renderAdminLogin(ctx, "The username or password was not accepted.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms, err := app.permissionsForUser(user)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !perms.AdminAccess {
|
||||||
|
app.renderAdminLogin(ctx, "This user does not have administrator access.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not create session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminLogout(ctx *gin.Context) {
|
||||||
|
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
|
||||||
|
_ = app.store.DeleteSession(token)
|
||||||
|
}
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminDashboard(ctx *gin.Context) {
|
||||||
|
ctx.HTML(http.StatusOK, "admin.html", gin.H{
|
||||||
|
"CurrentUser": app.currentAdminUsername(ctx),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries, err := boxstore.ListBoxSummaries()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not list boxes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]adminBoxRow, 0, len(summaries))
|
||||||
|
totalSize := int64(0)
|
||||||
|
expiredCount := 0
|
||||||
|
for _, summary := range summaries {
|
||||||
|
totalSize += summary.TotalSize
|
||||||
|
if summary.Expired {
|
||||||
|
expiredCount++
|
||||||
|
}
|
||||||
|
rows = append(rows, adminBoxRow{
|
||||||
|
ID: summary.ID,
|
||||||
|
FileCount: summary.FileCount,
|
||||||
|
TotalSizeLabel: summary.TotalSizeLabel,
|
||||||
|
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||||
|
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||||
|
Expired: summary.Expired,
|
||||||
|
OneTimeDownload: summary.OneTimeDownload,
|
||||||
|
PasswordProtected: summary.PasswordProtected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
|
||||||
|
"CurrentUser": app.currentAdminUsername(ctx),
|
||||||
|
"Boxes": rows,
|
||||||
|
"TotalBoxes": len(rows),
|
||||||
|
"TotalStorage": helpers.FormatBytes(totalSize),
|
||||||
|
"ExpiredBoxes": expiredCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderAdminUsers(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.PostForm("action") == "toggle_disabled" {
|
||||||
|
userID := strings.TrimSpace(ctx.PostForm("user_id"))
|
||||||
|
user, ok, err := app.store.GetUser(userID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
app.renderAdminUsers(ctx, "User not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current, ok := ctx.Get("adminUser"); ok {
|
||||||
|
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
|
||||||
|
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Disabled = !user.Disabled
|
||||||
|
if err := app.store.UpdateUser(user); err != nil {
|
||||||
|
app.renderAdminUsers(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := ctx.PostForm("username")
|
||||||
|
email := ctx.PostForm("email")
|
||||||
|
password := ctx.PostForm("password")
|
||||||
|
tagIDs := ctx.PostFormArray("tag_ids")
|
||||||
|
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
|
||||||
|
app.renderAdminUsers(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
|
||||||
|
users, err := app.store.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags, err := app.store.ListTags()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tagNames := make(map[string]string, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
tagNames[tag.ID] = tag.Name
|
||||||
|
}
|
||||||
|
sort.Slice(users, func(i int, j int) bool {
|
||||||
|
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
|
||||||
|
})
|
||||||
|
|
||||||
|
currentID := ""
|
||||||
|
if current, ok := ctx.Get("adminUser"); ok {
|
||||||
|
if currentUser, ok := current.(metastore.User); ok {
|
||||||
|
currentID = currentUser.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([]adminUserRow, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
names := make([]string, 0, len(user.TagIDs))
|
||||||
|
for _, tagID := range user.TagIDs {
|
||||||
|
if name := tagNames[tagID]; name != "" {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, adminUserRow{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Tags: strings.Join(names, ", "),
|
||||||
|
CreatedAt: formatAdminTime(user.CreatedAt),
|
||||||
|
Disabled: user.Disabled,
|
||||||
|
IsCurrent: user.ID == currentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
|
||||||
|
"CurrentUser": app.currentAdminUsername(ctx),
|
||||||
|
"Users": rows,
|
||||||
|
"Tags": tags,
|
||||||
|
"Error": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminTags(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderAdminTags(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms, err := parseTagPermissions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
app.renderAdminTags(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag := metastore.Tag{
|
||||||
|
Name: ctx.PostForm("name"),
|
||||||
|
Description: ctx.PostForm("description"),
|
||||||
|
Permissions: perms,
|
||||||
|
}
|
||||||
|
if err := app.store.CreateTag(&tag); err != nil {
|
||||||
|
app.renderAdminTags(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
|
||||||
|
tags, err := app.store.ListTags()
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not list tags")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(tags, func(i int, j int) bool {
|
||||||
|
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
|
||||||
|
})
|
||||||
|
rows := make([]adminTagRow, 0, len(tags))
|
||||||
|
for _, tag := range tags {
|
||||||
|
rows = append(rows, adminTagRow{
|
||||||
|
ID: tag.ID,
|
||||||
|
Name: tag.Name,
|
||||||
|
Description: tag.Description,
|
||||||
|
Protected: tag.Protected,
|
||||||
|
AdminAccess: tag.Permissions.AdminAccess,
|
||||||
|
UploadAllowed: tag.Permissions.UploadAllowed,
|
||||||
|
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
|
||||||
|
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
|
||||||
|
RenewableAllowed: tag.Permissions.RenewableAllowed,
|
||||||
|
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
|
||||||
|
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
|
||||||
|
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
|
||||||
|
"CurrentUser": app.currentAdminUsername(ctx),
|
||||||
|
"Tags": rows,
|
||||||
|
"Error": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.renderAdminSettings(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
||||||
|
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !app.config.AllowAdminSettingsOverride {
|
||||||
|
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, def := range config.EditableDefinitions() {
|
||||||
|
value := ctx.PostForm(def.Key)
|
||||||
|
if def.Type == config.SettingTypeBool {
|
||||||
|
value = "false"
|
||||||
|
if ctx.PostForm(def.Key) == "true" {
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := app.config.ApplyOverride(def.Key, value); err != nil {
|
||||||
|
app.renderAdminSettings(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := app.store.SetSetting(def.Key, value); err != nil {
|
||||||
|
app.renderAdminSettings(ctx, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
|
||||||
|
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
|
||||||
|
"CurrentUser": app.currentAdminUsername(ctx),
|
||||||
|
"Rows": app.config.SettingRows(),
|
||||||
|
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
|
||||||
|
"Error": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireAdminSession(ctx *gin.Context) {
|
||||||
|
token, err := ctx.Cookie(adminSessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, ok, err := app.store.GetSession(token)
|
||||||
|
if err != nil || !ok {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, ok, err := app.store.GetUser(session.UserID)
|
||||||
|
if err != nil || !ok || user.Disabled {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perms, err := app.permissionsForUser(user)
|
||||||
|
if err != nil || !perms.AdminAccess {
|
||||||
|
ctx.Redirect(http.StatusSeeOther, "/admin/login")
|
||||||
|
ctx.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Set("adminUser", user)
|
||||||
|
ctx.Set("adminPerms", perms)
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
|
||||||
|
token, err := ctx.Cookie(adminSessionCookie)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
session, ok, err := app.store.GetSession(token)
|
||||||
|
if err != nil || !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
user, ok, err := app.store.GetUser(session.UserID)
|
||||||
|
if err != nil || !ok || user.Disabled {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
perms, err := app.permissionsForUser(user)
|
||||||
|
return err == nil && perms.AdminAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
|
||||||
|
tags, err := app.store.TagsByID(user.TagIDs)
|
||||||
|
if err != nil {
|
||||||
|
return metastore.EffectivePermissions{}, err
|
||||||
|
}
|
||||||
|
return metastore.ResolveUserPermissions(app.config, user, tags), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
|
||||||
|
value, ok := ctx.Get("adminPerms")
|
||||||
|
if !ok {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
perms, ok := value.(metastore.EffectivePermissions)
|
||||||
|
if !ok || !allowed(perms) {
|
||||||
|
ctx.String(http.StatusForbidden, "Permission denied")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) currentAdminUsername(ctx *gin.Context) string {
|
||||||
|
if current, ok := ctx.Get("adminUser"); ok {
|
||||||
|
if user, ok := current.(metastore.User); ok {
|
||||||
|
return user.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
|
||||||
|
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
|
||||||
|
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||||
|
"Error": errorMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
|
||||||
|
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
|
||||||
|
if err != nil {
|
||||||
|
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
|
||||||
|
}
|
||||||
|
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
|
||||||
|
if err != nil {
|
||||||
|
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
|
||||||
|
}
|
||||||
|
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
|
||||||
|
if err != nil {
|
||||||
|
return metastore.TagPermissions{}, err
|
||||||
|
}
|
||||||
|
return metastore.TagPermissions{
|
||||||
|
UploadAllowed: checkbox(ctx, "upload_allowed"),
|
||||||
|
AllowedExpirySeconds: expirySeconds,
|
||||||
|
MaxFileSizeBytes: maxFileSize,
|
||||||
|
MaxBoxSizeBytes: maxBoxSize,
|
||||||
|
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
|
||||||
|
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
|
||||||
|
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
|
||||||
|
AdminAccess: checkbox(ctx, "admin_access"),
|
||||||
|
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
|
||||||
|
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
|
||||||
|
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkbox(ctx *gin.Context, name string) bool {
|
||||||
|
return ctx.PostForm(name) == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalInt64(raw string) (*int64, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("must be an integer")
|
||||||
|
}
|
||||||
|
if value < 0 {
|
||||||
|
return nil, errors.New("must be at least 0")
|
||||||
|
}
|
||||||
|
return &value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCSVInt64(raw string) ([]int64, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
values := make([]int64, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseInt(part, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
|
||||||
|
}
|
||||||
|
if value < 0 {
|
||||||
|
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
|
||||||
|
}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalInt64Label(value *int64) string {
|
||||||
|
if value == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(*value, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinInt64s(values []int64) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
parts = append(parts, strconv.FormatInt(value, 10))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAdminTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return value.Local().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -17,21 +18,24 @@ import (
|
|||||||
|
|
||||||
const boxAuthCookiePrefix = "warpbox_box_"
|
const boxAuthCookiePrefix = "warpbox_box_"
|
||||||
|
|
||||||
func handleIndex(ctx *gin.Context) {
|
func (app *App) handleIndex(ctx *gin.Context) {
|
||||||
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
ctx.HTML(http.StatusOK, "index.html", gin.H{
|
||||||
"RetentionOptions": boxstore.RetentionOptions(),
|
"RetentionOptions": app.retentionOptions(),
|
||||||
"DefaultRetention": boxstore.DefaultRetentionOption().Key,
|
"DefaultRetention": app.defaultRetentionOption().Key,
|
||||||
|
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
|
||||||
|
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
|
||||||
|
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleShowBox(ctx *gin.Context) {
|
func (app *App) handleShowBox(ctx *gin.Context) {
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, hasManifest, ok := authorizeBoxRequest(ctx, boxID, true)
|
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -43,7 +47,7 @@ func handleShowBox(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadAll := "/box/" + boxID + "/download"
|
downloadAll := "/box/" + boxID + "/download"
|
||||||
if hasManifest && manifest.DisableZip {
|
if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip {
|
||||||
downloadAll = ""
|
downloadAll = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +57,7 @@ func handleShowBox(ctx *gin.Context) {
|
|||||||
"FileCount": len(files),
|
"FileCount": len(files),
|
||||||
"DownloadAll": downloadAll,
|
"DownloadAll": downloadAll,
|
||||||
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
"ZipOnly": hasManifest && manifest.OneTimeDownload,
|
||||||
"PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000),
|
"PollMS": app.config.BoxPollIntervalMS,
|
||||||
"RetentionLabel": manifest.RetentionLabel,
|
"RetentionLabel": manifest.RetentionLabel,
|
||||||
"ExpiresAt": manifest.ExpiresAt,
|
"ExpiresAt": manifest.ExpiresAt,
|
||||||
})
|
})
|
||||||
@@ -122,14 +126,18 @@ func handleBoxLoginPost(ctx *gin.Context) {
|
|||||||
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
ctx.Redirect(http.StatusSeeOther, "/box/"+boxID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBoxStatus(ctx *gin.Context) {
|
func (app *App) handleBoxStatus(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, _, ok := authorizeBoxRequest(ctx, boxID, false); !ok {
|
if _, _, ok := app.authorizeBoxRequest(ctx, boxID, false); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,14 +150,19 @@ func handleBoxStatus(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "files": files})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "files": files})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDownloadBox(ctx *gin.Context) {
|
func (app *App) handleDownloadBox(ctx *gin.Context) {
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
ctx.String(http.StatusBadRequest, "Invalid box id")
|
ctx.String(http.StatusBadRequest, "Invalid box id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, hasManifest, ok := authorizeBoxRequest(ctx, boxID, true)
|
if !app.config.ZipDownloadsEnabled {
|
||||||
|
ctx.String(http.StatusForbidden, "Zip downloads are disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -200,6 +213,8 @@ func handleDownloadBox(ctx *gin.Context) {
|
|||||||
|
|
||||||
if hasManifest && manifest.OneTimeDownload {
|
if hasManifest && manifest.OneTimeDownload {
|
||||||
boxstore.DeleteBox(boxID)
|
boxstore.DeleteBox(boxID)
|
||||||
|
} else if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||||
|
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +232,7 @@ func allFilesComplete(files []models.BoxFile) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDownloadFile(ctx *gin.Context) {
|
func (app *App) handleDownloadFile(ctx *gin.Context) {
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
|
filename, ok := helpers.SafeFilename(ctx.Param("filename"))
|
||||||
if !boxstore.ValidBoxID(boxID) || !ok {
|
if !boxstore.ValidBoxID(boxID) || !ok {
|
||||||
@@ -225,7 +240,7 @@ func handleDownloadFile(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, hasManifest, authorized := authorizeBoxRequest(ctx, boxID, true)
|
manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true)
|
||||||
if !authorized {
|
if !authorized {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -246,9 +261,12 @@ func handleDownloadFile(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.FileAttachment(path, filename)
|
ctx.FileAttachment(path, filename)
|
||||||
|
if hasManifest && app.config.RenewOnDownloadEnabled {
|
||||||
|
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDownloadThumbnail(ctx *gin.Context) {
|
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
fileID := ctx.Param("file_id")
|
fileID := ctx.Param("file_id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
@@ -256,7 +274,7 @@ func handleDownloadThumbnail(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized {
|
if _, _, authorized := app.authorizeBoxRequest(ctx, boxID, true); !authorized {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +293,11 @@ func handleDownloadThumbnail(ctx *gin.Context) {
|
|||||||
ctx.File(path)
|
ctx.File(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateBox(ctx *gin.Context) {
|
func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID, err := boxstore.NewBoxID()
|
boxID, err := boxstore.NewBoxID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not create upload box"})
|
||||||
@@ -292,6 +314,10 @@ func handleCreateBox(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := app.validateCreateBoxRequest(&request); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
files, err := boxstore.CreateManifest(boxID, request)
|
files, err := boxstore.CreateManifest(boxID, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -302,7 +328,11 @@ func handleCreateBox(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleManifestFileUpload(ctx *gin.Context) {
|
func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
fileID := ctx.Param("file_id")
|
fileID := ctx.Param("file_id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
@@ -316,6 +346,11 @@ func handleManifestFileUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
|
||||||
|
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -327,7 +362,11 @@ func handleManifestFileUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleFileStatusUpdate(ctx *gin.Context) {
|
func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
fileID := ctx.Param("file_id")
|
fileID := ctx.Param("file_id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
@@ -350,7 +389,11 @@ func handleFileStatusUpdate(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDirectBoxUpload(ctx *gin.Context) {
|
func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
if !boxstore.ValidBoxID(boxID) {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"})
|
||||||
@@ -362,6 +405,10 @@ func handleDirectBoxUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveUpload(boxID, file)
|
savedFile, err := boxstore.SaveUpload(boxID, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -372,7 +419,11 @@ func handleDirectBoxUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLegacyUpload(ctx *gin.Context) {
|
func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||||
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
form, err := ctx.MultipartForm()
|
form, err := ctx.MultipartForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||||
@@ -384,6 +435,18 @@ func handleLegacyUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No files received"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range files {
|
||||||
|
if err := app.validateFileSize(file.Size); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
if err := app.validateBoxSize(totalSize); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID, err := boxstore.NewBoxID()
|
boxID, err := boxstore.NewBoxID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -410,7 +473,7 @@ func handleLegacyUpload(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
||||||
}
|
}
|
||||||
|
|
||||||
func authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
|
func (app *App) authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models.BoxManifest, bool, bool) {
|
||||||
manifest, err := boxstore.ReadManifest(boxID)
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.BoxManifest{}, false, true
|
return models.BoxManifest{}, false, true
|
||||||
@@ -435,6 +498,12 @@ func authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models
|
|||||||
return manifest, true, false
|
return manifest, true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if app.config.RenewOnAccessEnabled {
|
||||||
|
if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil {
|
||||||
|
manifest = renewed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return manifest, true, true
|
return manifest, true, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +516,155 @@ func boxAuthCookieName(boxID string) string {
|
|||||||
return boxAuthCookiePrefix + boxID
|
return boxAuthCookiePrefix + boxID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *App) requireAPI(ctx *gin.Context) bool {
|
||||||
|
if app.config.APIEnabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusForbidden, gin.H{"error": "API access is disabled"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) requireGuestUploads(ctx *gin.Context) bool {
|
||||||
|
if app.config.GuestUploadsEnabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusForbidden, gin.H{"error": "Guest uploads are disabled"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
|
||||||
|
if request == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !app.retentionAllowed(request.RetentionKey) {
|
||||||
|
return fmt.Errorf("Retention option is not allowed")
|
||||||
|
}
|
||||||
|
if !app.config.ZipDownloadsEnabled {
|
||||||
|
allowZip := false
|
||||||
|
request.AllowZip = &allowZip
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(request.RetentionKey) == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
||||||
|
return fmt.Errorf("One-time downloads are disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range request.Files {
|
||||||
|
if err := app.validateFileSize(file.Size); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
return app.validateBoxSize(totalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) validateIncomingFile(boxID string, size int64) error {
|
||||||
|
if err := app.validateFileSize(size); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := boxstore.ListFiles(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
totalSize := size
|
||||||
|
for _, file := range files {
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
return app.validateBoxSize(totalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
|
||||||
|
if err := app.validateFileSize(size); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
|
if err != nil {
|
||||||
|
return app.validateIncomingFile(boxID, size)
|
||||||
|
}
|
||||||
|
totalSize := int64(0)
|
||||||
|
found := false
|
||||||
|
for _, file := range manifest.Files {
|
||||||
|
if file.ID == fileID {
|
||||||
|
totalSize += size
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
totalSize += size
|
||||||
|
}
|
||||||
|
return app.validateBoxSize(totalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) validateFileSize(size int64) error {
|
||||||
|
if size < 0 {
|
||||||
|
return fmt.Errorf("File size cannot be negative")
|
||||||
|
}
|
||||||
|
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
|
||||||
|
return fmt.Errorf("File exceeds the global max file size")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) validateBoxSize(size int64) error {
|
||||||
|
if size < 0 {
|
||||||
|
return fmt.Errorf("Box size cannot be negative")
|
||||||
|
}
|
||||||
|
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
|
||||||
|
return fmt.Errorf("Box exceeds the global max box size")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) retentionAllowed(key string) bool {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, option := range app.retentionOptions() {
|
||||||
|
if option.Key == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) retentionOptions() []models.RetentionOption {
|
||||||
|
allOptions := boxstore.RetentionOptions()
|
||||||
|
options := make([]models.RetentionOption, 0, len(allOptions))
|
||||||
|
for _, option := range allOptions {
|
||||||
|
if option.Key == boxstore.OneTimeDownloadRetentionKey && !app.config.OneTimeDownloadsEnabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if option.Seconds > 0 && app.config.MaxGuestExpirySeconds > 0 && option.Seconds > app.config.MaxGuestExpirySeconds {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
options = append(options, option)
|
||||||
|
}
|
||||||
|
if len(options) == 0 {
|
||||||
|
return allOptions[:1]
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) defaultRetentionOption() models.RetentionOption {
|
||||||
|
options := app.retentionOptions()
|
||||||
|
for _, option := range options {
|
||||||
|
if option.Seconds == app.config.DefaultGuestExpirySeconds {
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options[0]
|
||||||
|
}
|
||||||
|
|
||||||
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
|
func renderBoxLogin(ctx *gin.Context, boxID string, errorMessage string) {
|
||||||
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
|
ctx.HTML(http.StatusOK, "box_login.html", gin.H{
|
||||||
"BoxID": boxID,
|
"BoxID": boxID,
|
||||||
|
|||||||
@@ -1,42 +1,84 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"warpbox/lib/boxstore"
|
"warpbox/lib/boxstore"
|
||||||
"warpbox/lib/helpers"
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/metastore"
|
||||||
"warpbox/lib/routing"
|
"warpbox/lib/routing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
config *config.Config
|
||||||
|
store *metastore.Store
|
||||||
|
adminLoginEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
func Run(addr string) error {
|
func Run(addr string) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cfg.EnsureDirectories(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
boxstore.SetUploadRoot(cfg.UploadsDir)
|
||||||
|
|
||||||
|
store, err := metastore.Open(cfg.DBDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open metadata database: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
overrides, err := store.ListSettings()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load settings overrides: %w", err)
|
||||||
|
}
|
||||||
|
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||||
|
return fmt.Errorf("apply settings overrides: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bootstrap admin metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
config: cfg,
|
||||||
|
store: store,
|
||||||
|
adminLoginEnabled: bootstrap.AdminLoginEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
router.LoadHTMLGlob("templates/*.html")
|
router.LoadHTMLGlob("templates/*.html")
|
||||||
|
|
||||||
routing.Register(router, routing.Handlers{
|
routing.Register(router, routing.Handlers{
|
||||||
Index: handleIndex,
|
Index: app.handleIndex,
|
||||||
ShowBox: handleShowBox,
|
ShowBox: app.handleShowBox,
|
||||||
BoxLogin: handleBoxLogin,
|
BoxLogin: handleBoxLogin,
|
||||||
BoxLoginPost: handleBoxLoginPost,
|
BoxLoginPost: handleBoxLoginPost,
|
||||||
BoxStatus: handleBoxStatus,
|
BoxStatus: app.handleBoxStatus,
|
||||||
DownloadBox: handleDownloadBox,
|
DownloadBox: app.handleDownloadBox,
|
||||||
DownloadFile: handleDownloadFile,
|
DownloadFile: app.handleDownloadFile,
|
||||||
DownloadThumbnail: handleDownloadThumbnail,
|
DownloadThumbnail: app.handleDownloadThumbnail,
|
||||||
CreateBox: handleCreateBox,
|
CreateBox: app.handleCreateBox,
|
||||||
ManifestFileUpload: handleManifestFileUpload,
|
ManifestFileUpload: app.handleManifestFileUpload,
|
||||||
FileStatusUpdate: handleFileStatusUpdate,
|
FileStatusUpdate: app.handleFileStatusUpdate,
|
||||||
DirectBoxUpload: handleDirectBoxUpload,
|
DirectBoxUpload: app.handleDirectBoxUpload,
|
||||||
LegacyUpload: handleLegacyUpload,
|
LegacyUpload: app.handleLegacyUpload,
|
||||||
})
|
})
|
||||||
|
app.registerAdminRoutes(router)
|
||||||
|
|
||||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||||
compressed.Static("/static", "./static")
|
compressed.Static("/static", "./static")
|
||||||
|
|
||||||
batchSize := helpers.EnvInt("WARPBOX_THUMBNAIL_BATCH_SIZE", 10, 1)
|
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
|
||||||
intervalSeconds := helpers.EnvInt("WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 30, 1)
|
|
||||||
boxstore.StartThumbnailWorker(batchSize, time.Duration(intervalSeconds)*time.Second)
|
|
||||||
|
|
||||||
return router.Run(addr)
|
return router.Run(addr)
|
||||||
}
|
}
|
||||||
|
|||||||
106
static/css/admin.css
Normal file
106
static/css/admin.css
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-window {
|
||||||
|
width: min(1120px, calc(100vw - 32px));
|
||||||
|
margin: 32px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link {
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 12px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link strong,
|
||||||
|
.admin-link span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-link span {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form-row input,
|
||||||
|
.admin-form-row textarea,
|
||||||
|
.admin-form-row select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checks {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-checks label {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-error {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #800;
|
||||||
|
background: #ffdede;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-summary span {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
39
templates/admin.html
Normal file
39
templates/admin.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WarpBox Admin</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="win98-window admin-window" aria-labelledby="admin-title">
|
||||||
|
<header class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1 id="admin-title">WarpBox Admin</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="win98-panel admin-panel">
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<span>Signed in as {{ .CurrentUser }}</span>
|
||||||
|
<span class="admin-spacer"></span>
|
||||||
|
<form action="/admin/logout" method="post">
|
||||||
|
<button class="win98-button" type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
<div class="admin-grid">
|
||||||
|
<a class="win98-panel admin-link" href="/admin/boxes"><strong>Boxes</strong></a>
|
||||||
|
<a class="win98-panel admin-link" href="/admin/users"><strong>Users</strong></a>
|
||||||
|
<a class="win98-panel admin-link" href="/admin/tags"><strong>Tags</strong></a>
|
||||||
|
<a class="win98-panel admin-link" href="/admin/settings"><strong>Settings</strong></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
69
templates/admin_boxes.html
Normal file
69
templates/admin_boxes.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WarpBox Admin Boxes</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="win98-window admin-window" aria-labelledby="admin-boxes-title">
|
||||||
|
<header class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1 id="admin-boxes-title">Boxes</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="win98-panel admin-panel">
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a class="win98-button" href="/admin">Admin</a>
|
||||||
|
<a class="win98-button" href="/admin/users">Users</a>
|
||||||
|
<a class="win98-button" href="/admin/tags">Tags</a>
|
||||||
|
<a class="win98-button" href="/admin/settings">Settings</a>
|
||||||
|
<span class="admin-spacer"></span>
|
||||||
|
<span>{{ .CurrentUser }}</span>
|
||||||
|
</nav>
|
||||||
|
<div class="admin-summary">
|
||||||
|
<span class="win98-panel">Boxes: {{ .TotalBoxes }}</span>
|
||||||
|
<span class="win98-panel">Storage: {{ .TotalStorage }}</span>
|
||||||
|
<span class="win98-panel">Expired: {{ .ExpiredBoxes }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Box ID</th>
|
||||||
|
<th>Files</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Boxes }}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/box/{{ .ID }}">{{ .ID }}</a></td>
|
||||||
|
<td>{{ .FileCount }}</td>
|
||||||
|
<td>{{ .TotalSizeLabel }}</td>
|
||||||
|
<td>{{ .CreatedAt }}</td>
|
||||||
|
<td>{{ .ExpiresAt }}</td>
|
||||||
|
<td>
|
||||||
|
{{ if .Expired }}expired {{ end }}
|
||||||
|
{{ if .OneTimeDownload }}one-time {{ end }}
|
||||||
|
{{ if .PasswordProtected }}password {{ end }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="6">No boxes found.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
templates/admin_login.html
Normal file
44
templates/admin_login.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WarpBox Admin Login</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="win98-window admin-window" aria-labelledby="admin-login-title">
|
||||||
|
<header class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1 id="admin-login-title">WarpBox Admin</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="win98-panel admin-panel">
|
||||||
|
{{ if .Error }}
|
||||||
|
<p class="admin-error">{{ .Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .AdminLoginEnabled }}
|
||||||
|
<form class="admin-form" action="/admin/login" method="post">
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button class="win98-button" type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
{{ else }}
|
||||||
|
<p>Administrator login is disabled. Set WARPBOX_ADMIN_PASSWORD and restart to bootstrap the first admin user.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
templates/admin_settings.html
Normal file
72
templates/admin_settings.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WarpBox Admin Settings</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="win98-window admin-window" aria-labelledby="admin-settings-title">
|
||||||
|
<header class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1 id="admin-settings-title">Settings</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="win98-panel admin-panel">
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a class="win98-button" href="/admin">Admin</a>
|
||||||
|
<a class="win98-button" href="/admin/boxes">Boxes</a>
|
||||||
|
<a class="win98-button" href="/admin/users">Users</a>
|
||||||
|
<a class="win98-button" href="/admin/tags">Tags</a>
|
||||||
|
<span class="admin-spacer"></span>
|
||||||
|
<span>{{ .CurrentUser }}</span>
|
||||||
|
</nav>
|
||||||
|
{{ if .Error }}
|
||||||
|
<p class="admin-error">{{ .Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form class="admin-form" action="/admin/settings" method="post">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Setting</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Env</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Rows }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .Definition.Label }}{{ if .Definition.HardLimit }} (hard){{ end }}</td>
|
||||||
|
<td>
|
||||||
|
{{ if and $.OverridesAllowed .Definition.Editable }}
|
||||||
|
{{ if eq .Definition.Type "bool" }}
|
||||||
|
<input type="checkbox" name="{{ .Definition.Key }}" value="true" {{ if eq .Value "true" }}checked{{ end }}>
|
||||||
|
{{ else }}
|
||||||
|
<input name="{{ .Definition.Key }}" value="{{ .Value }}" inputmode="numeric">
|
||||||
|
{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
{{ .Value }}
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td>{{ .Source }}</td>
|
||||||
|
<td>{{ .Definition.EnvName }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ if .OverridesAllowed }}
|
||||||
|
<button class="win98-button" type="submit">Save Settings</button>
|
||||||
|
{{ end }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
102
templates/admin_tags.html
Normal file
102
templates/admin_tags.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WarpBox Admin Tags</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="win98-window admin-window" aria-labelledby="admin-tags-title">
|
||||||
|
<header class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1 id="admin-tags-title">Tags</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="win98-panel admin-panel">
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a class="win98-button" href="/admin">Admin</a>
|
||||||
|
<a class="win98-button" href="/admin/boxes">Boxes</a>
|
||||||
|
<a class="win98-button" href="/admin/users">Users</a>
|
||||||
|
<a class="win98-button" href="/admin/settings">Settings</a>
|
||||||
|
<span class="admin-spacer"></span>
|
||||||
|
<span>{{ .CurrentUser }}</span>
|
||||||
|
</nav>
|
||||||
|
{{ if .Error }}
|
||||||
|
<p class="admin-error">{{ .Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form class="admin-form win98-panel" action="/admin/tags" method="post">
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Name</span>
|
||||||
|
<input name="name" required>
|
||||||
|
</label>
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea name="description" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="admin-checks">
|
||||||
|
<label><input type="checkbox" name="admin_access" value="true"><span>Admin access</span></label>
|
||||||
|
<label><input type="checkbox" name="admin_users_manage" value="true"><span>Manage users</span></label>
|
||||||
|
<label><input type="checkbox" name="admin_settings_manage" value="true"><span>Manage settings</span></label>
|
||||||
|
<label><input type="checkbox" name="admin_boxes_view" value="true"><span>View boxes</span></label>
|
||||||
|
<label><input type="checkbox" name="upload_allowed" value="true"><span>Upload allowed</span></label>
|
||||||
|
<label><input type="checkbox" name="zip_download_allowed" value="true"><span>ZIP allowed</span></label>
|
||||||
|
<label><input type="checkbox" name="one_time_download_allowed" value="true"><span>One-time allowed</span></label>
|
||||||
|
<label><input type="checkbox" name="renewable_allowed" value="true"><span>Renewable allowed</span></label>
|
||||||
|
</div>
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Max file size bytes</span>
|
||||||
|
<input name="max_file_size_bytes" inputmode="numeric">
|
||||||
|
</label>
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Max box size bytes</span>
|
||||||
|
<input name="max_box_size_bytes" inputmode="numeric">
|
||||||
|
</label>
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Allowed expiry seconds</span>
|
||||||
|
<input name="allowed_expiry_seconds" placeholder="600, 3600, 86400">
|
||||||
|
</label>
|
||||||
|
<button class="win98-button" type="submit">Create Tag</button>
|
||||||
|
</form>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Flags</th>
|
||||||
|
<th>Max file</th>
|
||||||
|
<th>Max box</th>
|
||||||
|
<th>Expiry seconds</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Tags }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .Name }} {{ if .Protected }}(protected){{ end }}</td>
|
||||||
|
<td>{{ .Description }}</td>
|
||||||
|
<td>
|
||||||
|
{{ if .AdminAccess }}admin {{ end }}
|
||||||
|
{{ if .UploadAllowed }}upload {{ end }}
|
||||||
|
{{ if .ZipDownloadAllowed }}zip {{ end }}
|
||||||
|
{{ if .OneTimeDownloadAllowed }}one-time {{ end }}
|
||||||
|
{{ if .RenewableAllowed }}renew {{ end }}
|
||||||
|
</td>
|
||||||
|
<td>{{ .MaxFileSizeBytes }}</td>
|
||||||
|
<td>{{ .MaxBoxSizeBytes }}</td>
|
||||||
|
<td>{{ .AllowedExpirySeconds }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="6">No tags found.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
92
templates/admin_users.html
Normal file
92
templates/admin_users.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WarpBox Admin Users</title>
|
||||||
|
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/window.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="win98-window admin-window" aria-labelledby="admin-users-title">
|
||||||
|
<header class="win98-titlebar">
|
||||||
|
<div class="win98-titlebar-label">
|
||||||
|
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
|
||||||
|
<h1 id="admin-users-title">Users</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="win98-panel admin-panel">
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a class="win98-button" href="/admin">Admin</a>
|
||||||
|
<a class="win98-button" href="/admin/boxes">Boxes</a>
|
||||||
|
<a class="win98-button" href="/admin/tags">Tags</a>
|
||||||
|
<a class="win98-button" href="/admin/settings">Settings</a>
|
||||||
|
<span class="admin-spacer"></span>
|
||||||
|
<span>{{ .CurrentUser }}</span>
|
||||||
|
</nav>
|
||||||
|
{{ if .Error }}
|
||||||
|
<p class="admin-error">{{ .Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form class="admin-form win98-panel" action="/admin/users" method="post">
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Username</span>
|
||||||
|
<input name="username" required>
|
||||||
|
</label>
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Email</span>
|
||||||
|
<input name="email" type="email">
|
||||||
|
</label>
|
||||||
|
<label class="admin-form-row">
|
||||||
|
<span>Password</span>
|
||||||
|
<input name="password" type="password" autocomplete="new-password" required>
|
||||||
|
</label>
|
||||||
|
<div class="admin-checks">
|
||||||
|
{{ range .Tags }}
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="tag_ids" value="{{ .ID }}">
|
||||||
|
<span>{{ .Name }}</span>
|
||||||
|
</label>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<button class="win98-button" type="submit">Create User</button>
|
||||||
|
</form>
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Tags</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Users }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .Username }}</td>
|
||||||
|
<td>{{ .Email }}</td>
|
||||||
|
<td>{{ .Tags }}</td>
|
||||||
|
<td>{{ .CreatedAt }}</td>
|
||||||
|
<td>{{ if .Disabled }}Disabled{{ else }}Active{{ end }}</td>
|
||||||
|
<td>
|
||||||
|
<form action="/admin/users" method="post">
|
||||||
|
<input type="hidden" name="action" value="toggle_disabled">
|
||||||
|
<input type="hidden" name="user_id" value="{{ .ID }}">
|
||||||
|
<button class="win98-button" type="submit" {{ if .IsCurrent }}disabled{{ end }}>{{ if .Disabled }}Enable{{ else }}Disable{{ end }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr><td colspan="6">No users found.</td></tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="upload-form" action="/upload" method="post" enctype="multipart/form-data">
|
<form class="upload-form" action="/upload" method="post" enctype="multipart/form-data" data-uploads-enabled="{{ if .UploadsEnabled }}true{{ else }}false{{ end }}">
|
||||||
<div class="win98-menu upload-menu" aria-hidden="true">
|
<div class="win98-menu upload-menu" aria-hidden="true">
|
||||||
<span class="win98-menu-option">File</span>
|
<span class="win98-menu-option">File</span>
|
||||||
<span class="win98-menu-option">Edit</span>
|
<span class="win98-menu-option">Edit</span>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<span class="upload-secondary">or click Browse to choose one</span>
|
<span class="upload-secondary">or click Browse to choose one</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input id="file-upload" class="upload-input" type="file" name="files" multiple>
|
<input id="file-upload" class="upload-input" type="file" name="files" multiple {{ if not .UploadsEnabled }}disabled{{ end }}>
|
||||||
|
|
||||||
<fieldset class="upload-options">
|
<fieldset class="upload-options">
|
||||||
<legend>Box options</legend>
|
<legend>Box options</legend>
|
||||||
@@ -76,13 +76,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="upload-file-list" aria-live="polite" aria-label="Selected files">
|
<div class="upload-file-list" aria-live="polite" aria-label="Selected files">
|
||||||
|
{{ if .UploadsEnabled }}
|
||||||
<p class="upload-empty-state">No files selected</p>
|
<p class="upload-empty-state">No files selected</p>
|
||||||
|
{{ else }}
|
||||||
|
<p class="upload-empty-state">Guest uploads are disabled.</p>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="upload-actions">
|
<footer class="upload-actions">
|
||||||
<label class="win98-button" for="file-upload">Browse...</label>
|
<label class="win98-button" for="file-upload">Browse...</label>
|
||||||
<button class="win98-button" type="submit">Upload</button>
|
<button class="win98-button" type="submit" {{ if not .UploadsEnabled }}disabled{{ end }}>Upload</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div class="upload-overall" aria-live="polite">
|
<div class="upload-overall" aria-live="polite">
|
||||||
|
|||||||
Reference in New Issue
Block a user