diff --git a/README.md b/README.md index 117e61a..dfa7fb0 100644 --- a/README.md +++ b/README.md @@ -71,36 +71,66 @@ go run ./cmd run --addr :3000 ## Configuration -WarpBox mostly works with defaults. These environment variables tune polling -and thumbnail generation. +WarpBox loads defaults, applies environment variables at startup, then applies +safe admin settings overrides from BadgerDB. Hard storage and global limit +settings remain environment controlled. -| Variable | Default | Minimum | What it does | -| --- | ---: | ---: | --- | -| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | `1000` | Browser polling interval for box/file status updates. | -| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | `1` | Number of pending thumbnails processed per worker pass. | -| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | `1` | Delay between thumbnail worker passes. | +| Variable | Default | What it does | +| --- | ---: | --- | +| `WARPBOX_DATA_DIR` | `./data` | Root directory for uploads and metadata. | +| `WARPBOX_ADMIN_PASSWORD` | empty | Bootstraps the first admin when set. | +| `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: ```bash +WARPBOX_ADMIN_PASSWORD='change-me' \ WARPBOX_BOX_POLL_INTERVAL_MS=2000 \ WARPBOX_THUMBNAIL_BATCH_SIZE=20 \ WARPBOX_THUMBNAIL_INTERVAL_SECONDS=10 \ go run ./cmd run --addr :8080 ``` +Open `/admin/login` after startup to sign in with the bootstrap admin. + ## Storage Uploads are stored locally under: ```text -data/uploads/ +/uploads/ ``` Each box gets its own directory containing the uploaded files and a `.warpbox.json` manifest. Image thumbnails are stored inside a box-local `.thumbnails` directory. +Persistent app metadata lives in BadgerDB under: + +```text +/db/ +``` + ```text data/uploads/ +-- / @@ -108,6 +138,7 @@ data/uploads/ +-- file.txt +-- .thumbnails/ +-- .jpg +data/db/ ``` ## Project Layout @@ -117,6 +148,8 @@ cmd/ CLI entrypoint lib/server/ HTTP handlers and server setup lib/routing/ Route registration 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/models/ Shared request/response models templates/ Server-rendered HTML diff --git a/docs/tech.md b/docs/tech.md index 9a6d40c..b34dc6d 100644 --- a/docs/tech.md +++ b/docs/tech.md @@ -86,13 +86,45 @@ Tuning is done with: ## 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 | -| --- | ---: | --- | -| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Status polling interval used by box pages. | -| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of thumbnail jobs per worker pass. | -| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. | +Storage paths are derived from `WARPBOX_DATA_DIR`: + +```text +/uploads +/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: @@ -106,10 +138,13 @@ go run ./cmd run --addr :8080 cmd/main.go CLI setup lib/server/server.go Gin engine setup and worker startup lib/server/handlers.go HTTP handlers +lib/server/admin.go Admin handlers lib/routing/routes.go Route table lib/boxstore/store.go Box manifests, uploads, downloads, retention lib/boxstore/thumbnails.go 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 ``` diff --git a/go.mod b/go.mod index 9299d4f..4b6f1aa 100644 --- a/go.mod +++ b/go.mod @@ -1,44 +1,53 @@ module warpbox -go 1.22 +go 1.23.0 require ( + github.com/dgraph-io/badger/v4 v4.8.0 github.com/gin-contrib/gzip v1.0.1 github.com/gin-gonic/gin v1.10.0 - github.com/spf13/cobra v1.8.1 + github.com/spf13/cobra v1.9.1 + golang.org/x/crypto v0.39.0 ) require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect - github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/rogpeppe/go-internal v1.10.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 956393b..1c11e0f 100644 --- a/go.sum +++ b/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/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs= +github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE= @@ -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-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 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/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/lib/boxstore/store.go b/lib/boxstore/store.go index c03e3c6..27e7010 100644 --- a/lib/boxstore/store.go +++ b/lib/boxstore/store.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -22,13 +23,15 @@ import ( ) const ( - UploadRoot = "data/uploads" manifestFile = ".warpbox.json" OneTimeDownloadRetentionKey = "one-time" ) -var manifestMu sync.Mutex +var ( + uploadRoot = filepath.Join("data", "uploads") + manifestMu sync.Mutex +) var retentionOptions = []models.RetentionOption{ {Key: "10s", Label: "10 seconds", Seconds: 10}, @@ -58,8 +61,19 @@ func DefaultRetentionOption() models.RetentionOption { return retentionOptions[0] } +func SetUploadRoot(path string) { + if path == "" { + return + } + uploadRoot = filepath.Clean(path) +} + +func UploadRoot() string { + return uploadRoot +} + func BoxPath(boxID string) string { - return filepath.Join(UploadRoot, boxID) + return filepath.Join(uploadRoot, boxID) } func ManifestPath(boxID string) string { @@ -74,6 +88,71 @@ func DeleteBox(boxID string) error { 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) { if manifest, err := reconcileManifest(boxID); err == nil && len(manifest.Files) > 0 { files := make([]models.BoxFile, 0, len(manifest.Files)) @@ -240,6 +319,21 @@ func WriteManifest(boxID string, manifest models.BoxManifest) error { 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 { source, err := os.Open(filepath.Join(BoxPath(boxID), filename)) if err != nil { diff --git a/lib/boxstore/thumbnails.go b/lib/boxstore/thumbnails.go index 0509913..2299c52 100644 --- a/lib/boxstore/thumbnails.go +++ b/lib/boxstore/thumbnails.go @@ -4,8 +4,8 @@ import ( "image" "image/color" "image/draw" - "image/jpeg" _ "image/gif" + "image/jpeg" _ "image/png" "net/url" "os" @@ -66,7 +66,7 @@ func ThumbnailFilePath(boxID string, fileID string) (string, bool) { } func collectThumbnailTasks(batchSize int) []thumbnailTask { - entries, err := os.ReadDir(UploadRoot) + entries, err := os.ReadDir(uploadRoot) if err != nil { return nil } diff --git a/lib/config/config.go b/lib/config/config.go new file mode 100644 index 0000000..e05c860 --- /dev/null +++ b/lib/config/config.go @@ -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" +} diff --git a/lib/config/config_test.go b/lib/config/config_test.go new file mode 100644 index 0000000..962f2c1 --- /dev/null +++ b/lib/config/config_test.go @@ -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, "") + } +} diff --git a/lib/metastore/bootstrap.go b/lib/metastore/bootstrap.go new file mode 100644 index 0000000..a8a807f --- /dev/null +++ b/lib/metastore/bootstrap.go @@ -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 +} diff --git a/lib/metastore/metastore_test.go b/lib/metastore/metastore_test.go new file mode 100644 index 0000000..cb2a1ec --- /dev/null +++ b/lib/metastore/metastore_test.go @@ -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, "") + } +} diff --git a/lib/metastore/models.go b/lib/metastore/models.go new file mode 100644 index 0000000..dc32ecc --- /dev/null +++ b/lib/metastore/models.go @@ -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 +} diff --git a/lib/metastore/permissions.go b/lib/metastore/permissions.go new file mode 100644 index 0000000..0a7954b --- /dev/null +++ b/lib/metastore/permissions.go @@ -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 +} diff --git a/lib/metastore/sessions.go b/lib/metastore/sessions.go new file mode 100644 index 0000000..87a767b --- /dev/null +++ b/lib/metastore/sessions.go @@ -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)) +} diff --git a/lib/metastore/store.go b/lib/metastore/store.go new file mode 100644 index 0000000..13fecad --- /dev/null +++ b/lib/metastore/store.go @@ -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 +} diff --git a/lib/metastore/tags.go b/lib/metastore/tags.go new file mode 100644 index 0000000..4252140 --- /dev/null +++ b/lib/metastore/tags.go @@ -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)) +} diff --git a/lib/models/models.go b/lib/models/models.go index c696d84..9789d38 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -54,6 +54,18 @@ type BoxManifest struct { 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 { Files []CreateBoxFileRequest `json:"files"` RetentionKey string `json:"retention_key"` diff --git a/lib/server/admin.go b/lib/server/admin.go new file mode 100644 index 0000000..ce6eecd --- /dev/null +++ b/lib/server/admin.go @@ -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") +} diff --git a/lib/server/handlers.go b/lib/server/handlers.go index 2a9554c..967ee16 100644 --- a/lib/server/handlers.go +++ b/lib/server/handlers.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "strings" "time" "github.com/gin-gonic/gin" @@ -17,21 +18,24 @@ import ( const boxAuthCookiePrefix = "warpbox_box_" -func handleIndex(ctx *gin.Context) { +func (app *App) handleIndex(ctx *gin.Context) { ctx.HTML(http.StatusOK, "index.html", gin.H{ - "RetentionOptions": boxstore.RetentionOptions(), - "DefaultRetention": boxstore.DefaultRetentionOption().Key, + "RetentionOptions": app.retentionOptions(), + "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") if !boxstore.ValidBoxID(boxID) { ctx.String(http.StatusBadRequest, "Invalid box id") return } - manifest, hasManifest, ok := authorizeBoxRequest(ctx, boxID, true) + manifest, hasManifest, ok := app.authorizeBoxRequest(ctx, boxID, true) if !ok { return } @@ -43,7 +47,7 @@ func handleShowBox(ctx *gin.Context) { } downloadAll := "/box/" + boxID + "/download" - if hasManifest && manifest.DisableZip { + if !app.config.ZipDownloadsEnabled || hasManifest && manifest.DisableZip { downloadAll = "" } @@ -53,7 +57,7 @@ func handleShowBox(ctx *gin.Context) { "FileCount": len(files), "DownloadAll": downloadAll, "ZipOnly": hasManifest && manifest.OneTimeDownload, - "PollMS": helpers.EnvInt("WARPBOX_BOX_POLL_INTERVAL_MS", 5000, 1000), + "PollMS": app.config.BoxPollIntervalMS, "RetentionLabel": manifest.RetentionLabel, "ExpiresAt": manifest.ExpiresAt, }) @@ -122,14 +126,18 @@ func handleBoxLoginPost(ctx *gin.Context) { 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") if !boxstore.ValidBoxID(boxID) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box id"}) return } - if _, _, ok := authorizeBoxRequest(ctx, boxID, false); !ok { + if _, _, ok := app.authorizeBoxRequest(ctx, boxID, false); !ok { return } @@ -142,14 +150,19 @@ func handleBoxStatus(ctx *gin.Context) { 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") if !boxstore.ValidBoxID(boxID) { ctx.String(http.StatusBadRequest, "Invalid box id") 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 { return } @@ -200,6 +213,8 @@ func handleDownloadBox(ctx *gin.Context) { if hasManifest && manifest.OneTimeDownload { 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 } -func handleDownloadFile(ctx *gin.Context) { +func (app *App) handleDownloadFile(ctx *gin.Context) { boxID := ctx.Param("id") filename, ok := helpers.SafeFilename(ctx.Param("filename")) if !boxstore.ValidBoxID(boxID) || !ok { @@ -225,7 +240,7 @@ func handleDownloadFile(ctx *gin.Context) { return } - manifest, hasManifest, authorized := authorizeBoxRequest(ctx, boxID, true) + manifest, hasManifest, authorized := app.authorizeBoxRequest(ctx, boxID, true) if !authorized { return } @@ -246,9 +261,12 @@ func handleDownloadFile(ctx *gin.Context) { } 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") fileID := ctx.Param("file_id") if !boxstore.ValidBoxID(boxID) { @@ -256,7 +274,7 @@ func handleDownloadThumbnail(ctx *gin.Context) { return } - if _, _, authorized := authorizeBoxRequest(ctx, boxID, true); !authorized { + if _, _, authorized := app.authorizeBoxRequest(ctx, boxID, true); !authorized { return } @@ -275,7 +293,11 @@ func handleDownloadThumbnail(ctx *gin.Context) { 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() if err != nil { 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"}) return } + if err := app.validateCreateBoxRequest(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } files, err := boxstore.CreateManifest(boxID, request) 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}) } -func handleManifestFileUpload(ctx *gin.Context) { +func (app *App) handleManifestFileUpload(ctx *gin.Context) { + if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { + return + } + boxID := ctx.Param("id") fileID := ctx.Param("file_id") if !boxstore.ValidBoxID(boxID) { @@ -316,6 +346,11 @@ func handleManifestFileUpload(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) 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) 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}) } -func handleFileStatusUpdate(ctx *gin.Context) { +func (app *App) handleFileStatusUpdate(ctx *gin.Context) { + if !app.requireAPI(ctx) { + return + } + boxID := ctx.Param("id") fileID := ctx.Param("file_id") if !boxstore.ValidBoxID(boxID) { @@ -350,7 +389,11 @@ func handleFileStatusUpdate(ctx *gin.Context) { 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") if !boxstore.ValidBoxID(boxID) { 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"}) 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) 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}) } -func handleLegacyUpload(ctx *gin.Context) { +func (app *App) handleLegacyUpload(ctx *gin.Context) { + if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { + return + } + form, err := ctx.MultipartForm() if err != nil { 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"}) 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() 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}) } -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) if err != nil { return models.BoxManifest{}, false, true @@ -435,6 +498,12 @@ func authorizeBoxRequest(ctx *gin.Context, boxID string, wantsHTML bool) (models return manifest, true, false } + if app.config.RenewOnAccessEnabled { + if renewed, err := boxstore.RenewManifest(boxID, manifest.RetentionSecs); err == nil { + manifest = renewed + } + } + return manifest, true, true } @@ -447,6 +516,155 @@ func boxAuthCookieName(boxID string) string { 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) { ctx.HTML(http.StatusOK, "box_login.html", gin.H{ "BoxID": boxID, diff --git a/lib/server/server.go b/lib/server/server.go index 018755f..46179ff 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -1,42 +1,84 @@ package server import ( + "fmt" "time" "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" "warpbox/lib/boxstore" - "warpbox/lib/helpers" + "warpbox/lib/config" + "warpbox/lib/metastore" "warpbox/lib/routing" ) +type App struct { + config *config.Config + store *metastore.Store + adminLoginEnabled bool +} + 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.LoadHTMLGlob("templates/*.html") routing.Register(router, routing.Handlers{ - Index: handleIndex, - ShowBox: handleShowBox, + Index: app.handleIndex, + ShowBox: app.handleShowBox, BoxLogin: handleBoxLogin, BoxLoginPost: handleBoxLoginPost, - BoxStatus: handleBoxStatus, - DownloadBox: handleDownloadBox, - DownloadFile: handleDownloadFile, - DownloadThumbnail: handleDownloadThumbnail, - CreateBox: handleCreateBox, - ManifestFileUpload: handleManifestFileUpload, - FileStatusUpdate: handleFileStatusUpdate, - DirectBoxUpload: handleDirectBoxUpload, - LegacyUpload: handleLegacyUpload, + BoxStatus: app.handleBoxStatus, + DownloadBox: app.handleDownloadBox, + DownloadFile: app.handleDownloadFile, + DownloadThumbnail: app.handleDownloadThumbnail, + CreateBox: app.handleCreateBox, + ManifestFileUpload: app.handleManifestFileUpload, + FileStatusUpdate: app.handleFileStatusUpdate, + DirectBoxUpload: app.handleDirectBoxUpload, + LegacyUpload: app.handleLegacyUpload, }) + app.registerAdminRoutes(router) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) compressed.Static("/static", "./static") - batchSize := helpers.EnvInt("WARPBOX_THUMBNAIL_BATCH_SIZE", 10, 1) - intervalSeconds := helpers.EnvInt("WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 30, 1) - boxstore.StartThumbnailWorker(batchSize, time.Duration(intervalSeconds)*time.Second) + boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second) return router.Run(addr) } diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..2dbbf1c --- /dev/null +++ b/static/css/admin.css @@ -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; +} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..3a398c8 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,39 @@ + + + + + + WarpBox Admin + + + + + + +
+
+
+
+ +

WarpBox Admin

+
+
+
+ +
+ Boxes + Users + Tags + Settings +
+
+
+
+ + diff --git a/templates/admin_boxes.html b/templates/admin_boxes.html new file mode 100644 index 0000000..7cbbb2e --- /dev/null +++ b/templates/admin_boxes.html @@ -0,0 +1,69 @@ + + + + + + WarpBox Admin Boxes + + + + + + +
+
+
+
+ +

Boxes

+
+
+
+ +
+ Boxes: {{ .TotalBoxes }} + Storage: {{ .TotalStorage }} + Expired: {{ .ExpiredBoxes }} +
+ + + + + + + + + + + + + {{ range .Boxes }} + + + + + + + + + {{ else }} + + {{ end }} + +
Box IDFilesSizeCreatedExpiresFlags
{{ .ID }}{{ .FileCount }}{{ .TotalSizeLabel }}{{ .CreatedAt }}{{ .ExpiresAt }} + {{ if .Expired }}expired {{ end }} + {{ if .OneTimeDownload }}one-time {{ end }} + {{ if .PasswordProtected }}password {{ end }} +
No boxes found.
+
+
+
+ + diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..637dd61 --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,44 @@ + + + + + + WarpBox Admin Login + + + + + + +
+
+
+
+ +

WarpBox Admin

+
+
+
+ {{ if .Error }} +

{{ .Error }}

+ {{ end }} + {{ if .AdminLoginEnabled }} +
+ + + +
+ {{ else }} +

Administrator login is disabled. Set WARPBOX_ADMIN_PASSWORD and restart to bootstrap the first admin user.

+ {{ end }} +
+
+
+ + diff --git a/templates/admin_settings.html b/templates/admin_settings.html new file mode 100644 index 0000000..aac02ca --- /dev/null +++ b/templates/admin_settings.html @@ -0,0 +1,72 @@ + + + + + + WarpBox Admin Settings + + + + + + +
+
+
+
+ +

Settings

+
+
+
+ + {{ if .Error }} +

{{ .Error }}

+ {{ end }} +
+ + + + + + + + + + + {{ range .Rows }} + + + + + + + {{ end }} + +
SettingValueSourceEnv
{{ .Definition.Label }}{{ if .Definition.HardLimit }} (hard){{ end }} + {{ if and $.OverridesAllowed .Definition.Editable }} + {{ if eq .Definition.Type "bool" }} + + {{ else }} + + {{ end }} + {{ else }} + {{ .Value }} + {{ end }} + {{ .Source }}{{ .Definition.EnvName }}
+ {{ if .OverridesAllowed }} + + {{ end }} +
+
+
+
+ + diff --git a/templates/admin_tags.html b/templates/admin_tags.html new file mode 100644 index 0000000..e76c6ea --- /dev/null +++ b/templates/admin_tags.html @@ -0,0 +1,102 @@ + + + + + + WarpBox Admin Tags + + + + + + +
+
+
+
+ +

Tags

+
+
+
+ + {{ if .Error }} +

{{ .Error }}

+ {{ end }} +
+ + +
+ + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + {{ range .Tags }} + + + + + + + + + {{ else }} + + {{ end }} + +
NameDescriptionFlagsMax fileMax boxExpiry seconds
{{ .Name }} {{ if .Protected }}(protected){{ end }}{{ .Description }} + {{ if .AdminAccess }}admin {{ end }} + {{ if .UploadAllowed }}upload {{ end }} + {{ if .ZipDownloadAllowed }}zip {{ end }} + {{ if .OneTimeDownloadAllowed }}one-time {{ end }} + {{ if .RenewableAllowed }}renew {{ end }} + {{ .MaxFileSizeBytes }}{{ .MaxBoxSizeBytes }}{{ .AllowedExpirySeconds }}
No tags found.
+
+
+
+ + diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100644 index 0000000..77c4833 --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,92 @@ + + + + + + WarpBox Admin Users + + + + + + +
+
+
+
+ +

Users

+
+
+
+ + {{ if .Error }} +

{{ .Error }}

+ {{ end }} +
+ + + +
+ {{ range .Tags }} + + {{ end }} +
+ +
+ + + + + + + + + + + + + {{ range .Users }} + + + + + + + + + {{ else }} + + {{ end }} + +
UsernameEmailTagsCreatedStatusAction
{{ .Username }}{{ .Email }}{{ .Tags }}{{ .CreatedAt }}{{ if .Disabled }}Disabled{{ else }}Active{{ end }} +
+ + + +
+
No users found.
+
+
+
+ + diff --git a/templates/index.html b/templates/index.html index 0b7c30d..0e2ab03 100644 --- a/templates/index.html +++ b/templates/index.html @@ -26,7 +26,7 @@ -
+
-

No files selected

+ {{ if .UploadsEnabled }} +

No files selected

+ {{ else }} +

Guest uploads are disabled.

+ {{ end }}
- +