10 Commits

Author SHA1 Message Date
42030003d3 feat(ci/cd): Implemented simple package publishing to registry
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 1m1s
2026-05-01 03:39:33 +03:00
25bc095412 feat(docker): add healthcheck and wget dependency
Adds a healthcheck endpoint and updates Dockerfile/README
to include wget and define healthcheck logic.
2026-05-01 03:31:01 +03:00
54bb68642f chore(docker): update build path and volume comment 2026-05-01 02:58:16 +03:00
9b57b2a535 feat(routing): add user admin panel support
Adds the user administration route, associated server handlers, and frontend assets for managing user accounts.
2026-05-01 02:34:47 +03:00
1cf38d126d refactor(storage): standardize size limits to use GB units 2026-05-01 02:14:05 +03:00
d0aa86205f feat(setting): Implemented the settings administrative menu 2026-05-01 01:51:06 +03:00
36d49a970e feat(admin): add full alerts dashboard functionality 2026-05-01 00:46:10 +03:00
3844473eb3 feat(admin): implement full admin dashboard structure 2026-05-01 00:29:06 +03:00
5f3f63b710 'cleanup(admin): This large-scale refactoring effort involves cleaning up redundant logic and standardizing database interactions across several modules
Reviewed-on: kato/WarpBox#1
2026-04-30 22:08:12 +03:00
9951cfc8b6 cleanup(admin): This large-scale refactoring effort involves cleaning up redundant logic and standardizing database interactions across several modules. 2026-04-30 22:06:45 +03:00
65 changed files with 7646 additions and 2508 deletions

View File

@@ -0,0 +1,45 @@
name: Build and Publish Docker Image
run-name: Publishing ${{ gitea.ref_name }}
on:
push:
tags:
- "v*"
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Run Tests
run: go test ./...
- name: Install Docker
run: curl -fsSL https://get.docker.com | sh
- name: Build Docker Image
run: |
docker build \
-t tea.chunkbyte.com/kato/WarpBox:${{ gitea.ref_name }} \
-t tea.chunkbyte.com/kato/WarpBox:latest \
.
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: tea.chunkbyte.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Push Docker Image
run: |
docker push tea.chunkbyte.com/kato/WarpBox:${{ gitea.ref_name }}
docker push tea.chunkbyte.com/kato/WarpBox:latest

View File

@@ -16,12 +16,16 @@ COPY static/ static/
COPY templates/ templates/
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
# Stage 2: Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
RUN apk add \
--no-cache \
ca-certificates \
tzdata \
wget
# Create non-root user
RUN addgroup -S warpbox && adduser -S warpbox -G warpbox
@@ -50,8 +54,8 @@ ENV WARPBOX_DATA_DIR=/app/data \
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
WARPBOX_ADMIN_ENABLED=true \
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \
WARPBOX_GLOBAL_MAX_FILE_SIZE_GB=2 \
WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
@@ -60,6 +64,9 @@ ENV WARPBOX_DATA_DIR=/app/data \
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
VOLUME ["/app/data"]
CMD ["./warpbox", "run", "--addr", ":8080"]

View File

@@ -86,8 +86,8 @@ go run ./cmd run --addr :3000
## Configuration
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.
safe admin settings overrides from BadgerDB. Storage path settings remain
environment controlled.
| Variable | Default | What it does |
| --- | ---: | --- |
@@ -108,16 +108,16 @@ settings remain environment controlled.
| `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_GLOBAL_MAX_FILE_SIZE_GB` | `0` | Per-file cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed, like `0.5`. |
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB` | `0` | Per-box cap in GB using `1024^3` conversion; `0` means unlimited. Decimals allowed. |
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` | `0` | Default user file cap in GB using `1024^3` conversion. |
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` | `0` | Default user box cap in GB using `1024^3` conversion. |
| `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. |
Size limits also accept `_MB` variants for the same settings.
Legacy `_MB` and `_BYTES` size env names are still accepted for compatibility, but GB env names are the intended format now. GB input uses `1024^3` bytes so UI limits and displayed space stay consistent.
Example:
@@ -189,3 +189,25 @@ keeps most behavior easy to follow from the Go handlers and the small browser
scripts.
For a short implementation overview, see [docs/tech.md](docs/tech.md).
## Docker / Podman
If you are using Podman, please pay attention in the [docker-compose.yml](./docker-compose.example.yml) example
file that has been provided, there's comments in regards to differences between the two.
When it comes to building the image, please make sure that you basically set the `--format docker` in the podman
build command, otherwise it won't have HealthChecks and other issues might arise.
Tip: Put the following in `~/.config/containers/containers.conf`
```toml
[engine]
image_default_format = "docker"
```
For just running the docker-compose.yml with docker image format:
```bash
BUILDAH_FORMAT=docker podman compose up --build
```

View File

@@ -151,11 +151,6 @@ func buildAllEnvRows(includeHidden bool) []envRow {
}
extra := buildExtraEnvRows(includeHidden)
if loadErr == nil {
for i := range extra {
extra[i].Default = extra[i].Default
}
}
rows = append(rows, extra...)
return rows
@@ -180,22 +175,6 @@ func buildExtraEnvRows(includeHidden bool) []envRow {
{EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"},
}
sizePairs := []struct {
bytesEnv string
mbEnv string
label string
}{
{"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "Global max file size"},
{"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "Global max box size"},
{"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "Default user max file size"},
{"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "Default user max box size"},
}
for _, pair := range sizePairs {
extra = append(extra, envRow{EnvName: pair.bytesEnv, Key: pair.bytesEnv, Label: pair.label + " (bytes)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
extra = append(extra, envRow{EnvName: pair.mbEnv, Key: pair.mbEnv, Label: pair.label + " (MB)", Type: config.SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0, Default: "(use bytes or MB variant)"})
}
return extra
}

View File

@@ -5,6 +5,8 @@ services:
ports:
- "8080:8080"
volumes:
# For podman please use :Z
# - ./data:/app/data:Z
- ./data:/app/data
env_file:
- .env

View File

@@ -150,16 +150,16 @@ Primary environment variables:
- `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_GLOBAL_MAX_FILE_SIZE_GB`
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB`
- `WARPBOX_SESSION_TTL_SECONDS`
- `WARPBOX_BOX_POLL_INTERVAL_MS`
- `WARPBOX_THUMBNAIL_BATCH_SIZE`
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
Size limit settings accept `_MB` or `_BYTES` env names. `WARPBOX_ADMIN_ENABLED`
Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED`
accepts `auto`, `true`, or `false`.
The HTTP listen address is configured through the CLI flag:

18
go.mod
View File

@@ -3,51 +3,43 @@ module warpbox
go 1.23.0
require (
github.com/dgraph-io/badger/v4 v4.8.0
github.com/gin-contrib/gzip v1.0.1
github.com/gin-gonic/gin v1.10.0
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
golang.org/x/crypto v0.39.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

33
go.sum
View File

@@ -2,24 +2,15 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
@@ -28,11 +19,6 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -43,8 +29,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -52,14 +36,15 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -73,8 +58,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -99,14 +86,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=

View File

@@ -153,6 +153,43 @@ func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func ExpireBox(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func BumpBoxExpiry(boxID string, delta time.Duration) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()
manifest, err := readManifestUnlocked(boxID)
if err != nil {
return manifest, err
}
if delta <= 0 {
return manifest, fmt.Errorf("Invalid bump duration")
}
if manifest.OneTimeDownload {
return manifest, fmt.Errorf("One-time boxes cannot be extended")
}
base := manifest.ExpiresAt
now := time.Now().UTC()
if base.IsZero() || base.Before(now) {
base = now
}
manifest.ExpiresAt = base.Add(delta)
return manifest, writeManifestUnlocked(boxID, manifest)
}
func reconcileManifest(boxID string) (models.BoxManifest, error) {
manifestMu.Lock()
defer manifestMu.Unlock()

View File

@@ -204,3 +204,57 @@ func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) {
t.Fatal("expected legacy password hash to verify")
}
}
func TestExpireBoxMarksManifestExpired(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "0123456789abcdef0123456789abcdef"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
manifest := models.BoxManifest{
CreatedAt: time.Now().UTC().Add(-time.Hour),
ExpiresAt: time.Now().UTC().Add(time.Hour),
}
if err := WriteManifest(boxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
expired, err := ExpireBox(boxID)
if err != nil {
t.Fatalf("ExpireBox returned error: %v", err)
}
if !expired.ExpiresAt.Before(time.Now().UTC()) {
t.Fatalf("expected expired manifest time in past, got %s", expired.ExpiresAt)
}
}
func TestBumpBoxExpiryExtendsFutureExpiry(t *testing.T) {
restoreUploadRoot := UploadRoot()
defer SetUploadRoot(restoreUploadRoot)
SetUploadRoot(t.TempDir())
boxID := "fedcba9876543210fedcba9876543210"
if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
base := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
manifest := models.BoxManifest{
CreatedAt: time.Now().UTC().Add(-time.Hour),
ExpiresAt: base,
}
if err := WriteManifest(boxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
bumped, err := BumpBoxExpiry(boxID, 24*time.Hour)
if err != nil {
t.Fatalf("BumpBoxExpiry returned error: %v", err)
}
expected := base.Add(24 * time.Hour)
if bumped.ExpiresAt.Before(expected.Add(-time.Second)) || bumped.ExpiresAt.After(expected.Add(time.Second)) {
t.Fatalf("expected bumped expiry near %s, got %s", expected, bumped.ExpiresAt)
}
}

View File

@@ -35,7 +35,7 @@ func TestEnvironmentOverrides(t *testing.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_GLOBAL_MAX_FILE_SIZE_GB", "0.5")
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
@@ -51,7 +51,7 @@ func TestEnvironmentOverrides(t *testing.T) {
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
t.Fatal("expected boolean environment overrides to be applied")
}
if cfg.GlobalMaxFileSizeBytes != 100 {
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
if cfg.BoxPollIntervalMS != 2000 {
@@ -70,25 +70,25 @@ func TestEnvironmentOverrides(t *testing.T) {
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "4096")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "4")
cfg, err := Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 {
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 {
if cfg.GlobalMaxBoxSizeBytes != 4*1024*1024*1024 {
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
}
}
func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T) {
clearConfigEnv(t)
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
cfg, err := Load()
@@ -96,7 +96,7 @@ func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 100 {
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
}
}
@@ -145,8 +145,14 @@ func TestSettingsOverrideValidation(t *testing.T) {
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")
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "0.5"); err != nil {
t.Fatalf("expected global max file size override to succeed, got %v", err)
}
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
}
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
t.Fatal("expected data_dir override to remain locked")
}
}
@@ -169,12 +175,16 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_GB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",

View File

@@ -12,10 +12,10 @@ var Definitions = []SettingDefinition{
{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: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", Label: "Global max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", Label: "Global max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", Label: "Default user max file size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", Label: "Default user max box size GB", Type: SettingTypeSizeGB, 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},
@@ -50,6 +50,7 @@ func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
}
func Definition(key string) (SettingDefinition, bool) {
key = NormalizeLegacySettingKey(key)
for _, def := range Definitions {
if def.Key == key {
return def, true
@@ -58,6 +59,35 @@ func Definition(key string) (SettingDefinition, bool) {
return SettingDefinition{}, false
}
func NormalizeLegacySettingKey(key string) string {
switch key {
case "global_max_file_size_bytes":
return SettingGlobalMaxFileSizeBytes
case "global_max_box_size_bytes":
return SettingGlobalMaxBoxSizeBytes
case "default_user_max_file_size_bytes":
return SettingDefaultUserMaxFileBytes
case "default_user_max_box_size_bytes":
return SettingDefaultUserMaxBoxBytes
default:
return key
}
}
func NormalizeOverrideInput(key string, value string) (string, string, error) {
normalizedKey := NormalizeLegacySettingKey(key)
switch key {
case "global_max_file_size_bytes", "global_max_box_size_bytes", "default_user_max_file_size_bytes", "default_user_max_box_size_bytes":
parsed, err := parseInt64(value, 0)
if err != nil {
return normalizedKey, "", err
}
return normalizedKey, formatGigabytesFromBytes(parsed), nil
default:
return normalizedKey, value, nil
}
}
func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions {

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
@@ -29,6 +28,7 @@ func Load() (*Config, error) {
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
defaults: make(map[string]string),
}
// Config precedence: defaults -> env -> overrides.
@@ -98,17 +98,18 @@ func Load() (*Config, error) {
}
sizeEnvVars := []struct {
key string
gbName string
mbName string
bytesName string
target *int64
}{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes},
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
return nil, err
}
}
@@ -152,25 +153,32 @@ func (cfg *Config) EnsureDirectories() error {
return nil
}
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(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), 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)
cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
cfg.captureDefaultValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled))
cfg.captureDefaultValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10))
cfg.captureDefaultValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure))
cfg.captureDefaultValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled))
cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxFileSizeBytes))
cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxBoxSizeBytes))
cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxFileSizeBytes))
cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxBoxSizeBytes))
cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
}
func (cfg *Config) captureDefaultValue(key string, value string) {
cfg.setValue(key, value, SourceDefault)
if cfg.defaults != nil {
cfg.defaults[key] = value
}
}
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
@@ -217,14 +225,23 @@ func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int
return nil
}
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
func (cfg *Config) applySizeEnv(key string, gbName string, mbName string, bytesName string, min int64, target *int64) error {
if rawGB := strings.TrimSpace(os.Getenv(gbName)); rawGB != "" {
parsed, err := parseGigabytes(rawGB, float64(min))
if err != nil {
return fmt.Errorf("%s: %w", gbName, err)
}
*target = parsed
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
return nil
}
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
parsed, err := parseInt64(rawBytes, min)
if err != nil {
return fmt.Errorf("%s: %w", bytesName, err)
}
*target = parsed
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
return nil
}
@@ -236,12 +253,9 @@ func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName
if err != nil {
return fmt.Errorf("%s: %w", mbName, err)
}
if parsedMB > math.MaxInt64/(1024*1024) {
return fmt.Errorf("%s: is too large", mbName)
}
parsedBytes := parsedMB * 1024 * 1024
parsedBytes := parsedMB * 1000 * 1000
*target = parsedBytes
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), SourceEnv)
return nil
}

View File

@@ -27,10 +27,10 @@ const (
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"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_gb"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_gb"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_gb"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_gb"
SettingSessionTTLSeconds = "session_ttl_seconds"
SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailBatchSize = "thumbnail_batch_size"
@@ -45,6 +45,7 @@ const (
SettingTypeInt64 SettingType = "int64"
SettingTypeInt SettingType = "int"
SettingTypeText SettingType = "text"
SettingTypeSizeGB SettingType = "size_gb"
)
type SettingDefinition struct {
@@ -97,4 +98,5 @@ type Config struct {
sources map[string]Source
values map[string]string
defaults map[string]string
}

View File

@@ -0,0 +1,76 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"time"
)
const AdminSettingsOverrideFilename = "admin_settings_overrides.json"
type adminSettingsOverrideFile struct {
Format string `json:"format"`
SavedAt string `json:"saved_at"`
Overrides map[string]string `json:"overrides"`
}
func ReadAdminSettingsOverrides(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
var payload adminSettingsOverrideFile
if err := json.Unmarshal(data, &payload); err != nil {
return nil, err
}
if payload.Overrides == nil {
return map[string]string{}, nil
}
normalized := make(map[string]string, len(payload.Overrides))
for key, value := range payload.Overrides {
nextKey, nextValue, err := NormalizeOverrideInput(key, value)
if err != nil {
return nil, err
}
normalized[nextKey] = nextValue
}
return normalized, nil
}
func WriteAdminSettingsOverrides(path string, overrides map[string]string) error {
if overrides == nil {
overrides = map[string]string{}
}
keys := make([]string, 0, len(overrides))
for key := range overrides {
keys = append(keys, key)
}
sort.Strings(keys)
normalized := make(map[string]string, len(overrides))
for _, key := range keys {
normalized[key] = overrides[key]
}
payload := adminSettingsOverrideFile{
Format: "warpbox.admin.settings.overrides.v1",
SavedAt: time.Now().UTC().Format(time.RFC3339),
Overrides: normalized,
}
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

View File

@@ -39,6 +39,12 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt64(key, parsed, SourceDB)
case SettingTypeSizeGB:
parsed, err := parseGigabytes(value, float64(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 {
@@ -76,6 +82,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value
case SettingGlobalMaxFileSizeBytes:
cfg.GlobalMaxFileSizeBytes = value
case SettingGlobalMaxBoxSizeBytes:
cfg.GlobalMaxBoxSizeBytes = value
case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes:
@@ -83,6 +93,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
}
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
cfg.setValue(key, formatGigabytesFromBytes(value), source)
return
}
cfg.setValue(key, strconv.FormatInt(value, 10), source)
}
@@ -113,3 +127,10 @@ func (cfg *Config) sourceFor(key string) Source {
}
return source
}
func (cfg *Config) DefaultValue(key string) string {
if cfg.defaults == nil {
return ""
}
return cfg.defaults[key]
}

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"math"
"strconv"
"strings"
)
@@ -39,6 +40,46 @@ func parseInt(value string, min int) (int, error) {
return int(parsed64), nil
}
const bytesPerGigabyte = 1024 * 1024 * 1024
func parseGigabytes(value string, min float64) (int64, error) {
raw := strings.TrimSpace(value)
lower := strings.ToLower(raw)
if strings.HasSuffix(lower, "gb") {
raw = strings.TrimSpace(raw[:len(raw)-2])
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("must be a number of GB")
}
if parsed < min {
return 0, fmt.Errorf("must be at least %s", trimTrailingZeros(min))
}
bytes := parsed * bytesPerGigabyte
if bytes > math.MaxInt64 {
return 0, fmt.Errorf("is too large")
}
return int64(math.Round(bytes)), nil
}
func formatGigabytesFromBytes(bytes int64) string {
if bytes <= 0 {
return "0"
}
value := float64(bytes) / bytesPerGigabyte
return trimTrailingZeros(value)
}
func trimTrailingZeros(value float64) string {
text := strconv.FormatFloat(value, 'f', 3, 64)
text = strings.TrimRight(text, "0")
text = strings.TrimRight(text, ".")
if text == "" {
return "0"
}
return text
}
func formatBool(value bool) string {
if value {
return "true"

View File

@@ -1,71 +0,0 @@
package metastore
import (
"strings"
"warpbox/lib/config"
)
func BootstrapAdmin(cfg *config.Config, store *Store) (BootstrapResult, error) {
adminTag, err := store.EnsureAdminTag()
if err != nil {
return BootstrapResult{}, err
}
var adminUser *User
user, ok, err := store.GetUserByUsername(cfg.AdminUsername)
if err != nil {
return BootstrapResult{}, err
}
if ok {
if !hasString(user.TagIDs, adminTag.ID) {
user.TagIDs = append(user.TagIDs, adminTag.ID)
if err := store.UpdateUser(user); err != nil {
return BootstrapResult{}, err
}
}
adminUser = &user
} else if strings.TrimSpace(cfg.AdminPassword) != "" {
created, err := store.CreateUserWithPassword(cfg.AdminUsername, cfg.AdminEmail, cfg.AdminPassword, []string{adminTag.ID})
if err != nil {
return BootstrapResult{}, err
}
adminUser = &created
}
hasAdminUser, err := store.HasAdminUser(adminTag.ID)
if err != nil {
return BootstrapResult{}, err
}
return BootstrapResult{
AdminTag: adminTag,
AdminUser: adminUser,
AdminLoginEnabled: cfg.AdminLoginEnabled(hasAdminUser),
}, nil
}
func (store *Store) HasAdminUser(adminTagID string) (bool, error) {
users, err := store.ListUsers()
if err != nil {
return false, err
}
for _, user := range users {
if user.Disabled {
continue
}
if hasString(user.TagIDs, adminTagID) {
return true, nil
}
}
return false, nil
}
func hasString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -1,222 +0,0 @@
package metastore
import (
"errors"
"testing"
"time"
"warpbox/lib/config"
)
func TestOpenClose(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
if err := store.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}
func TestBootstrapAdminFromPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret-pass")
t.Setenv("WARPBOX_ADMIN_EMAIL", "admin@example.test")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if !result.AdminLoginEnabled {
t.Fatal("expected admin login to be enabled")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to be protected")
}
if result.AdminUser == nil {
t.Fatal("expected bootstrap admin user")
}
if !hasString(result.AdminUser.TagIDs, result.AdminTag.ID) {
t.Fatal("expected bootstrap admin to have admin tag")
}
if !VerifyPassword(result.AdminUser.PasswordHash, "secret-pass") {
t.Fatal("expected bootstrap admin password to verify")
}
}
func TestBootstrapAdminDisabledWithoutPassword(t *testing.T) {
clearMetastoreConfigEnv(t)
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
store := openTestStore(t)
result, err := BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if result.AdminLoginEnabled {
t.Fatal("expected admin login to be disabled without password or existing admin")
}
if !result.AdminTag.Protected {
t.Fatal("expected admin tag to still be created")
}
users, err := store.ListUsers()
if err != nil {
t.Fatalf("ListUsers returned error: %v", err)
}
if len(users) != 0 {
t.Fatalf("expected no users, got %d", len(users))
}
}
func TestDuplicateUsersAndTags(t *testing.T) {
store := openTestStore(t)
if _, err := store.CreateUserWithPassword("alex", "alex@example.test", "secret", nil); err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
if _, err := store.CreateUserWithPassword("Alex", "other@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate username error, got %v", err)
}
if _, err := store.CreateUserWithPassword("other", "alex@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate email error, got %v", err)
}
tag := Tag{Name: "staff"}
if err := store.CreateTag(&tag); err != nil {
t.Fatalf("CreateTag returned error: %v", err)
}
duplicate := Tag{Name: "Staff"}
if err := store.CreateTag(&duplicate); !errors.Is(err, ErrDuplicate) {
t.Fatalf("expected duplicate tag error, got %v", err)
}
}
func TestPermissionResolutionAndGlobalCaps(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "50")
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "1000")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
tagFileLimit := int64(80)
tagBoxLimit := int64(2000)
userFileLimit := int64(60)
user := User{MaxFileSizeBytes: &userFileLimit}
tags := []Tag{
{
Permissions: TagPermissions{
UploadAllowed: true,
AllowedExpirySeconds: []int64{3600, 600},
MaxFileSizeBytes: &tagFileLimit,
MaxBoxSizeBytes: &tagBoxLimit,
ZipDownloadAllowed: true,
},
},
}
perms := ResolveUserPermissions(cfg, user, tags)
if !perms.UploadAllowed || !perms.ZipDownloadAllowed {
t.Fatal("expected tag booleans to grant permissions")
}
if perms.MaxFileSizeBytes != 80 {
t.Fatalf("expected tag limit to beat user/default limit, got %d", perms.MaxFileSizeBytes)
}
if perms.MaxBoxSizeBytes != 1000 {
t.Fatalf("expected global max box cap, got %d", perms.MaxBoxSizeBytes)
}
if len(perms.AllowedExpirySeconds) != 2 || perms.AllowedExpirySeconds[0] != 600 || perms.AllowedExpirySeconds[1] != 3600 {
t.Fatalf("unexpected expiry durations: %#v", perms.AllowedExpirySeconds)
}
}
func TestSettingsStorageAndPrecedence(t *testing.T) {
clearMetastoreConfigEnv(t)
t.Setenv("WARPBOX_API_ENABLED", "true")
store := openTestStore(t)
if err := store.SetSetting(config.SettingAPIEnabled, "false"); err != nil {
t.Fatalf("SetSetting returned error: %v", err)
}
overrides, err := store.ListSettings()
if err != nil {
t.Fatalf("ListSettings returned error: %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.ApplyOverrides(overrides); err != nil {
t.Fatalf("ApplyOverrides returned error: %v", err)
}
if cfg.APIEnabled {
t.Fatal("expected stored DB override to beat env")
}
}
func TestSessionExpiry(t *testing.T) {
store := openTestStore(t)
session, err := store.CreateSession("user-id", time.Millisecond)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
time.Sleep(2 * time.Millisecond)
if _, ok, err := store.GetSession(session.Token); err != nil || ok {
t.Fatalf("expected expired session to be invalid, ok=%v err=%v", ok, err)
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func clearMetastoreConfigEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"WARPBOX_DATA_DIR",
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
"WARPBOX_GUEST_UPLOADS_ENABLED",
"WARPBOX_API_ENABLED",
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
"WARPBOX_SESSION_TTL_SECONDS",
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}

View File

@@ -1,76 +0,0 @@
package metastore
import "time"
const AdminTagName = "admin"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
PasswordHash string `json:"password_hash"`
TagIDs []string `json:"tag_ids"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Disabled bool `json:"disabled"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Protected bool `json:"protected"`
Permissions TagPermissions `json:"permissions"`
}
type TagPermissions struct {
UploadAllowed bool `json:"upload_allowed"`
AllowedExpirySeconds []int64 `json:"allowed_expiry_seconds,omitempty"`
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
OneTimeDownloadAllowed bool `json:"one_time_download_allowed"`
ZipDownloadAllowed bool `json:"zip_download_allowed"`
RenewableAllowed bool `json:"renewable_allowed"`
RenewOnAccessSeconds int64 `json:"renew_on_access_seconds,omitempty"`
RenewOnDownloadSeconds int64 `json:"renew_on_download_seconds,omitempty"`
AdminAccess bool `json:"admin_access"`
AdminUsersManage bool `json:"admin_users_manage"`
AdminSettingsManage bool `json:"admin_settings_manage"`
AdminBoxesView bool `json:"admin_boxes_view"`
}
type Session struct {
Token string `json:"token"`
CSRFToken string `json:"csrf_token"`
UserID string `json:"user_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type EffectivePermissions struct {
UploadAllowed bool
AllowedExpirySeconds []int64
MaxFileSizeBytes int64
MaxBoxSizeBytes int64
MaxExpirySeconds int64
OneTimeDownloadAllowed bool
ZipDownloadAllowed bool
RenewableAllowed bool
RenewOnAccessSeconds int64
RenewOnDownloadSeconds int64
AdminAccess bool
AdminUsersManage bool
AdminSettingsManage bool
AdminBoxesView bool
}
type BootstrapResult struct {
AdminTag Tag
AdminUser *User
AdminLoginEnabled bool
}

View File

@@ -1,141 +0,0 @@
package metastore
import (
"sort"
"warpbox/lib/config"
)
func ResolveUserPermissions(cfg *config.Config, user User, tags []Tag) EffectivePermissions {
perms := EffectivePermissions{
MaxFileSizeBytes: cfg.DefaultUserMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.DefaultUserMaxBoxSizeBytes,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
}
expirySet := make(map[int64]bool)
for _, tag := range tags {
tagPerms := tag.Permissions
perms.UploadAllowed = perms.UploadAllowed || tagPerms.UploadAllowed
perms.OneTimeDownloadAllowed = perms.OneTimeDownloadAllowed || tagPerms.OneTimeDownloadAllowed
perms.ZipDownloadAllowed = perms.ZipDownloadAllowed || tagPerms.ZipDownloadAllowed
perms.RenewableAllowed = perms.RenewableAllowed || tagPerms.RenewableAllowed
perms.AdminAccess = perms.AdminAccess || tagPerms.AdminAccess
perms.AdminUsersManage = perms.AdminUsersManage || tagPerms.AdminUsersManage
perms.AdminSettingsManage = perms.AdminSettingsManage || tagPerms.AdminSettingsManage
perms.AdminBoxesView = perms.AdminBoxesView || tagPerms.AdminBoxesView
perms.RenewOnAccessSeconds = maxInt64(perms.RenewOnAccessSeconds, tagPerms.RenewOnAccessSeconds)
perms.RenewOnDownloadSeconds = maxInt64(perms.RenewOnDownloadSeconds, tagPerms.RenewOnDownloadSeconds)
if tagPerms.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *tagPerms.MaxFileSizeBytes)
}
if tagPerms.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *tagPerms.MaxBoxSizeBytes)
}
for _, seconds := range tagPerms.AllowedExpirySeconds {
if seconds >= 0 {
expirySet[seconds] = true
}
}
}
if user.MaxFileSizeBytes != nil {
perms.MaxFileSizeBytes = morePermissiveLimit(perms.MaxFileSizeBytes, *user.MaxFileSizeBytes)
}
if user.MaxBoxSizeBytes != nil {
perms.MaxBoxSizeBytes = morePermissiveLimit(perms.MaxBoxSizeBytes, *user.MaxBoxSizeBytes)
}
if user.MaxExpirySeconds != nil {
perms.MaxExpirySeconds = *user.MaxExpirySeconds
}
perms.MaxFileSizeBytes = capLimit(perms.MaxFileSizeBytes, cfg.GlobalMaxFileSizeBytes)
perms.MaxBoxSizeBytes = capLimit(perms.MaxBoxSizeBytes, cfg.GlobalMaxBoxSizeBytes)
perms.AllowedExpirySeconds = sortedExpirySet(expirySet)
if !cfg.ZipDownloadsEnabled {
perms.ZipDownloadAllowed = false
}
if !cfg.OneTimeDownloadsEnabled {
perms.OneTimeDownloadAllowed = false
}
return perms
}
func ResolveGuestPermissions(cfg *config.Config) EffectivePermissions {
return EffectivePermissions{
UploadAllowed: cfg.GuestUploadsEnabled,
AllowedExpirySeconds: guestExpirySeconds(cfg),
MaxFileSizeBytes: cfg.GlobalMaxFileSizeBytes,
MaxBoxSizeBytes: cfg.GlobalMaxBoxSizeBytes,
MaxExpirySeconds: cfg.MaxGuestExpirySeconds,
OneTimeDownloadAllowed: cfg.OneTimeDownloadsEnabled,
ZipDownloadAllowed: cfg.ZipDownloadsEnabled,
RenewableAllowed: cfg.RenewOnAccessEnabled || cfg.RenewOnDownloadEnabled,
}
}
func morePermissiveLimit(current int64, candidate int64) int64 {
if current == 0 || candidate == 0 {
return 0
}
if candidate > current {
return candidate
}
return current
}
func capLimit(value int64, globalMax int64) int64 {
if globalMax == 0 {
return value
}
if value == 0 || value > globalMax {
return globalMax
}
return value
}
func sortedExpirySet(expirySet map[int64]bool) []int64 {
values := make([]int64, 0, len(expirySet))
for value := range expirySet {
values = append(values, value)
}
sort.Slice(values, func(i int, j int) bool {
return values[i] < values[j]
})
return values
}
func guestExpirySeconds(cfg *config.Config) []int64 {
values := []int64{}
if cfg.DefaultGuestExpirySeconds >= 0 {
values = append(values, cfg.DefaultGuestExpirySeconds)
}
if cfg.MaxGuestExpirySeconds > 0 && cfg.MaxGuestExpirySeconds != cfg.DefaultGuestExpirySeconds {
values = append(values, cfg.MaxGuestExpirySeconds)
}
return uniqueInt64s(values)
}
func uniqueInt64s(values []int64) []int64 {
seen := make(map[int64]bool, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i int, j int) bool {
return out[i] < out[j]
})
return out
}
func maxInt64(a int64, b int64) int64 {
if b > a {
return b
}
return a
}

View File

@@ -1,79 +0,0 @@
package metastore
import (
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func (store *Store) CreateSession(userID string, ttl time.Duration) (Session, error) {
userID = strings.TrimSpace(userID)
if userID == "" {
return Session{}, fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
if ttl <= 0 {
return Session{}, fmt.Errorf("%w: session ttl must be positive", ErrInvalid)
}
token, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
csrfToken, err := helpers.RandomHexID(32)
if err != nil {
return Session{}, err
}
now := time.Now().UTC()
session := Session{
Token: token,
CSRFToken: csrfToken,
UserID: userID,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
}
err = store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, sessionKey(token), session)
})
return session, err
}
func (store *Store) GetSession(token string) (Session, bool, error) {
token = strings.TrimSpace(token)
if token == "" {
return Session{}, false, nil
}
var session Session
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, sessionKey(token), &session)
})
if errors.Is(err, ErrNotFound) {
return Session{}, false, nil
}
if err != nil {
return Session{}, false, err
}
if time.Now().UTC().After(session.ExpiresAt) {
_ = store.DeleteSession(token)
return Session{}, false, nil
}
return session, true, nil
}
func (store *Store) DeleteSession(token string) error {
return store.db.Update(func(txn *badger.Txn) error {
err := txn.Delete(sessionKey(token))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
func sessionKey(token string) []byte {
return []byte("session/" + strings.TrimSpace(token))
}

View File

@@ -1,379 +0,0 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"golang.org/x/crypto/bcrypt"
"warpbox/lib/helpers"
)
var (
ErrNotFound = errors.New("not found")
ErrDuplicate = errors.New("duplicate")
ErrInvalid = errors.New("invalid")
)
type Store struct {
db *badger.DB
}
func Open(path string) (*Store, error) {
opts := badger.DefaultOptions(path).WithLogger(nil)
db, err := badger.Open(opts)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (store *Store) Close() error {
if store == nil || store.db == nil {
return nil
}
return store.db.Close()
}
func (store *Store) SetSetting(name string, value string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: setting name cannot be empty", ErrInvalid)
}
return store.db.Update(func(txn *badger.Txn) error {
return txn.Set(settingKey(name), []byte(value))
})
}
func (store *Store) DeleteSetting(name string) error {
return store.db.Update(func(txn *badger.Txn) error {
return txn.Delete(settingKey(name))
})
}
func (store *Store) GetSetting(name string) (string, bool, error) {
var value string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(settingKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
value = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return "", false, nil
}
return value, err == nil, err
}
func (store *Store) ListSettings() (map[string]string, error) {
settings := make(map[string]string)
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("setting/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
name := strings.TrimPrefix(string(item.Key()), "setting/")
if err := item.Value(func(data []byte) error {
settings[name] = string(data)
return nil
}); err != nil {
return err
}
}
return nil
})
return settings, err
}
func HashPassword(password string) (string, error) {
if strings.TrimSpace(password) == "" {
return "", fmt.Errorf("%w: password cannot be empty", ErrInvalid)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func VerifyPassword(hash string, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (store *Store) CreateUserWithPassword(username string, email string, password string, tagIDs []string) (User, error) {
hash, err := HashPassword(password)
if err != nil {
return User{}, err
}
user := User{
Username: username,
Email: email,
PasswordHash: hash,
TagIDs: uniqueStrings(tagIDs),
}
if err := store.CreateUser(&user); err != nil {
return User{}, err
}
return user, nil
}
func (store *Store) CreateUser(user *User) error {
if user == nil {
return fmt.Errorf("%w: user cannot be nil", ErrInvalid)
}
username := strings.TrimSpace(user.Username)
if username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
email := strings.TrimSpace(user.Email)
if user.PasswordHash == "" {
return fmt.Errorf("%w: password hash cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if user.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
user.ID = id
}
user.Username = username
user.Email = email
user.TagIDs = uniqueStrings(user.TagIDs)
user.CreatedAt = now
user.UpdatedAt = now
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, usernameKey(username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if email != "" {
if exists, err := keyExists(txn, emailKey(email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
}
if err := putJSON(txn, userKey(user.ID), user); err != nil {
return err
}
if err := txn.Set(usernameKey(username), []byte(user.ID)); err != nil {
return err
}
if email != "" {
return txn.Set(emailKey(email), []byte(user.ID))
}
return nil
})
}
func (store *Store) UpdateUser(user User) error {
if strings.TrimSpace(user.ID) == "" {
return fmt.Errorf("%w: user id cannot be empty", ErrInvalid)
}
user.Username = strings.TrimSpace(user.Username)
user.Email = strings.TrimSpace(user.Email)
if user.Username == "" {
return fmt.Errorf("%w: username cannot be empty", ErrInvalid)
}
user.TagIDs = uniqueStrings(user.TagIDs)
user.UpdatedAt = time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
var existing User
if err := getJSON(txn, userKey(user.ID), &existing); err != nil {
return err
}
oldUsername := normalizeIndex(existing.Username)
newUsername := normalizeIndex(user.Username)
if oldUsername != newUsername {
if exists, err := keyExists(txn, usernameKey(user.Username)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: username already exists", ErrDuplicate)
}
if err := txn.Delete(usernameKey(existing.Username)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(usernameKey(user.Username), []byte(user.ID)); err != nil {
return err
}
}
oldEmail := normalizeIndex(existing.Email)
newEmail := normalizeIndex(user.Email)
if oldEmail != newEmail {
if newEmail != "" {
if exists, err := keyExists(txn, emailKey(user.Email)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: email already exists", ErrDuplicate)
}
if err := txn.Set(emailKey(user.Email), []byte(user.ID)); err != nil {
return err
}
}
if oldEmail != "" {
if err := txn.Delete(emailKey(existing.Email)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
}
}
return putJSON(txn, userKey(user.ID), user)
})
}
func (store *Store) GetUser(id string) (User, bool, error) {
var user User
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, userKey(id), &user)
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
return user, err == nil, err
}
func (store *Store) GetUserByUsername(username string) (User, bool, error) {
return store.getUserByIndex(usernameKey(username))
}
func (store *Store) GetUserByEmail(email string) (User, bool, error) {
return store.getUserByIndex(emailKey(email))
}
func (store *Store) ListUsers() ([]User, error) {
users := []User{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("user/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var user User
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &user)
}); err != nil {
return err
}
users = append(users, user)
}
return nil
})
return users, err
}
func (store *Store) getUserByIndex(key []byte) (User, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
return store.GetUser(id)
}
func putJSON(txn *badger.Txn, key []byte, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
return txn.Set(key, data)
}
func getJSON(txn *badger.Txn, key []byte, value any) error {
item, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
return json.Unmarshal(data, value)
})
}
func keyExists(txn *badger.Txn, key []byte) (bool, error) {
_, err := txn.Get(key)
if errors.Is(err, badger.ErrKeyNotFound) {
return false, nil
}
return err == nil, err
}
func settingKey(name string) []byte {
return []byte("setting/" + strings.TrimSpace(name))
}
func userKey(id string) []byte {
return []byte("user/" + strings.TrimSpace(id))
}
func usernameKey(username string) []byte {
return []byte("user_by_name/" + normalizeIndex(username))
}
func emailKey(email string) []byte {
return []byte("user_by_email/" + normalizeIndex(email))
}
func normalizeIndex(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func uniqueStrings(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}

View File

@@ -1,220 +0,0 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
func AdminPermissions() TagPermissions {
unlimited := int64(0)
return TagPermissions{
UploadAllowed: true,
MaxFileSizeBytes: &unlimited,
MaxBoxSizeBytes: &unlimited,
OneTimeDownloadAllowed: true,
ZipDownloadAllowed: true,
RenewableAllowed: true,
AdminAccess: true,
AdminUsersManage: true,
AdminSettingsManage: true,
AdminBoxesView: true,
}
}
func (store *Store) EnsureAdminTag() (Tag, error) {
tag, ok, err := store.GetTagByName(AdminTagName)
if err != nil {
return Tag{}, err
}
if ok {
tag.Protected = true
tag.Permissions = AdminPermissions()
tag.Description = "Built-in administrator permissions"
if err := store.UpdateTag(tag); err != nil {
return Tag{}, err
}
return tag, nil
}
tag = Tag{
Name: AdminTagName,
Description: "Built-in administrator permissions",
Protected: true,
Permissions: AdminPermissions(),
}
if err := store.CreateTag(&tag); err != nil {
return Tag{}, err
}
return tag, nil
}
func (store *Store) CreateTag(tag *Tag) error {
if tag == nil {
return fmt.Errorf("%w: tag cannot be nil", ErrInvalid)
}
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
now := time.Now().UTC()
if tag.ID == "" {
id, err := helpers.RandomHexID(16)
if err != nil {
return err
}
tag.ID = id
}
tag.CreatedAt = now
tag.UpdatedAt = now
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := putJSON(txn, tagKey(tag.ID), tag); err != nil {
return err
}
return txn.Set(tagNameKey(tag.Name), []byte(tag.ID))
})
}
func (store *Store) UpdateTag(tag Tag) error {
tag.Name = strings.TrimSpace(tag.Name)
tag.Description = strings.TrimSpace(tag.Description)
if tag.ID == "" {
return fmt.Errorf("%w: tag id cannot be empty", ErrInvalid)
}
if tag.Name == "" {
return fmt.Errorf("%w: tag name cannot be empty", ErrInvalid)
}
tag.UpdatedAt = time.Now().UTC()
normalizeTagPermissions(&tag.Permissions)
return store.db.Update(func(txn *badger.Txn) error {
var existing Tag
if err := getJSON(txn, tagKey(tag.ID), &existing); err != nil {
return err
}
if normalizeIndex(existing.Name) != normalizeIndex(tag.Name) {
if exists, err := keyExists(txn, tagNameKey(tag.Name)); err != nil || exists {
if err != nil {
return err
}
return fmt.Errorf("%w: tag name already exists", ErrDuplicate)
}
if err := txn.Delete(tagNameKey(existing.Name)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
return err
}
if err := txn.Set(tagNameKey(tag.Name), []byte(tag.ID)); err != nil {
return err
}
}
if existing.Protected {
tag.Protected = true
}
if tag.Name == AdminTagName {
tag.Protected = true
tag.Permissions = AdminPermissions()
}
return putJSON(txn, tagKey(tag.ID), tag)
})
}
func (store *Store) GetTag(id string) (Tag, bool, error) {
var tag Tag
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, tagKey(id), &tag)
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
return tag, err == nil, err
}
func (store *Store) GetTagByName(name string) (Tag, bool, error) {
var id string
err := store.db.View(func(txn *badger.Txn) error {
item, err := txn.Get(tagNameKey(name))
if errors.Is(err, badger.ErrKeyNotFound) {
return ErrNotFound
}
if err != nil {
return err
}
return item.Value(func(data []byte) error {
id = string(data)
return nil
})
})
if errors.Is(err, ErrNotFound) {
return Tag{}, false, nil
}
if err != nil {
return Tag{}, false, err
}
return store.GetTag(id)
}
func (store *Store) ListTags() ([]Tag, error) {
tags := []Tag{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("tag/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var tag Tag
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &tag)
}); err != nil {
return err
}
tags = append(tags, tag)
}
return nil
})
return tags, err
}
func (store *Store) TagsByID(ids []string) ([]Tag, error) {
tags := make([]Tag, 0, len(ids))
for _, id := range ids {
tag, ok, err := store.GetTag(id)
if err != nil {
return nil, err
}
if ok {
tags = append(tags, tag)
}
}
return tags, nil
}
func normalizeTagPermissions(perms *TagPermissions) {
if perms == nil {
return
}
perms.AllowedExpirySeconds = uniqueInt64s(perms.AllowedExpirySeconds)
}
func tagKey(id string) []byte {
return []byte("tag/" + strings.TrimSpace(id))
}
func tagNameKey(name string) []byte {
return []byte("tag_by_name/" + normalizeIndex(name))
}

View File

@@ -3,6 +3,7 @@ package routing
import "github.com/gin-gonic/gin"
type Handlers struct {
Health gin.HandlerFunc
Index gin.HandlerFunc
ShowBox gin.HandlerFunc
BoxLogin gin.HandlerFunc
@@ -16,9 +17,25 @@ type Handlers struct {
FileStatusUpdate gin.HandlerFunc
DirectBoxUpload gin.HandlerFunc
LegacyUpload gin.HandlerFunc
AdminLogin gin.HandlerFunc
AdminLoginPost gin.HandlerFunc
AdminLogout gin.HandlerFunc
AdminDashboard gin.HandlerFunc
AdminAlerts gin.HandlerFunc
AdminBoxes gin.HandlerFunc
AdminBoxesAction gin.HandlerFunc
AdminUsers gin.HandlerFunc
AdminSettings gin.HandlerFunc
AdminSettingsExport gin.HandlerFunc
AdminSettingsSave gin.HandlerFunc
AdminSettingsImport gin.HandlerFunc
AdminSettingsReset gin.HandlerFunc
AdminAuth gin.HandlerFunc
}
func Register(router *gin.Engine, handlers Handlers) {
router.GET("/health", handlers.Health)
router.GET("/", handlers.Index)
router.GET("/box/:id", handlers.ShowBox)
@@ -36,4 +53,21 @@ func Register(router *gin.Engine, handlers Handlers) {
// Legacy upload routes are kept for compatibility with older clients.
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
router.POST("/upload", handlers.LegacyUpload)
admin := router.Group("/admin")
admin.GET("/login", handlers.AdminLogin)
admin.POST("/login", handlers.AdminLoginPost)
admin.GET("/logout", handlers.AdminLogout)
protected := router.Group("/admin", handlers.AdminAuth)
protected.GET("/dashboard", handlers.AdminDashboard)
protected.GET("/alerts", handlers.AdminAlerts)
protected.GET("/boxes", handlers.AdminBoxes)
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
protected.GET("/users", handlers.AdminUsers)
protected.GET("/settings", handlers.AdminSettings)
protected.GET("/settings/export", handlers.AdminSettingsExport)
protected.POST("/settings/save", handlers.AdminSettingsSave)
protected.POST("/settings/import", handlers.AdminSettingsImport)
protected.POST("/settings/reset", handlers.AdminSettingsReset)
}

116
lib/server/admin.go Normal file
View File

@@ -0,0 +1,116 @@
package server
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
)
const adminSessionCookie = "warpbox_admin_session"
const adminSessionMarker = "1"
func (app *App) adminLoginEnabled() bool {
return app.config.AdminLoginEnabled(app.config.AdminPassword != "")
}
func (app *App) adminAuthMiddleware(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
ctx.Abort()
return
}
token, err := ctx.Cookie(adminSessionCookie)
if err != nil || token != app.adminSessionToken() {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Next()
}
func (app *App) adminSessionToken() string {
// A simple deterministic token derived from the admin credentials.
// This will improve when proper user/session storage is added.
return app.config.AdminUsername + ":" + app.config.AdminPassword
}
func (app *App) handleAdminLogin(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
// Already logged in.
if token, err := ctx.Cookie(adminSessionCookie); err == nil && token == app.adminSessionToken() {
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
return
}
ctx.HTML(http.StatusOK, "admin/login.html", gin.H{})
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
if username != app.config.AdminUsername || password != app.config.AdminPassword {
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
"ErrorMessage": "Invalid username or password.",
})
return
}
secure := app.config.AdminCookieSecure
maxAge := int(app.config.SessionTTLSeconds)
ctx.SetCookie(adminSessionCookie, app.adminSessionToken(), maxAge, "/admin", "", secure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
secure := app.config.AdminCookieSecure
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", secure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) handleAdminDashboard(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
dashboardEnabled := config.AdminEnabledTrue
if cfgVal := app.config.AdminEnabled; cfgVal == config.AdminEnabledAuto || cfgVal == config.AdminEnabledTrue {
dashboardEnabled = cfgVal
}
ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "dashboard",
"DashboardEnabled": string(dashboardEnabled),
})
}
func (app *App) handleAdminAlerts(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "alerts",
})
}

View File

@@ -1,192 +0,0 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
const adminSessionCookie = "warpbox_admin_session"
func (app *App) handleAdminLogin(ctx *gin.Context) {
if app.isAdminSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/admin")
return
}
app.renderAdminLogin(ctx, "")
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAdminLogin(ctx, "Administrator login is disabled.")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
user, ok, err := app.store.GetUserByUsername(username)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load user")
return
}
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
app.renderAdminLogin(ctx, "The username or password was not accepted.")
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
if !perms.AdminAccess {
app.renderAdminLogin(ctx, "This user does not have administrator access.")
return
}
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not create session")
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(adminSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) requireAdminSession(ctx *gin.Context) {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil || !perms.AdminAccess {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Set("adminUser", user)
ctx.Set("adminPerms", perms)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAdminSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(adminSessionCookie)
if err != nil {
return false
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
return false
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
return false
}
perms, err := app.permissionsForUser(user)
return err == nil && perms.AdminAccess
}
func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) {
tags, err := app.store.TagsByID(user.TagIDs)
if err != nil {
return metastore.EffectivePermissions{}, err
}
return metastore.ResolveUserPermissions(app.config, user, tags), nil
}
func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool {
value, ok := ctx.Get("adminPerms")
if !ok {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok || !allowed(perms) {
ctx.String(http.StatusForbidden, "Permission denied")
return false
}
return true
}
func (app *App) currentAdminUsername(ctx *gin.Context) string {
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user.Username
}
}
return ""
}
func (app *App) currentCSRFToken(ctx *gin.Context) string {
if value, ok := ctx.Get("adminCSRFToken"); ok {
if token, ok := value.(string); ok {
return token
}
}
return ""
}
func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_login.html", gin.H{
"AdminLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func noStoreAdminHeaders(ctx *gin.Context) {
ctx.Header("Cache-Control", "no-store")
ctx.Header("Pragma", "no-cache")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Next()
}
func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
switch ctx.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
token := ctx.PostForm("csrf_token")
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}
func subtleConstantTimeEqual(a string, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}

View File

@@ -1,63 +1,316 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
type adminBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Expired bool
OneTimeDownload bool
PasswordProtected bool
type adminBoxesActionRequest struct {
Action string `json:"action"`
BoxIDs []string `json:"box_ids"`
DeltaSeconds int64 `json:"delta_seconds,omitempty"`
}
type adminBoxFileView struct {
Name string `json:"name"`
SizeLabel string `json:"size_label"`
MimeType string `json:"mime_type"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
DownloadPath string `json:"download_path"`
ThumbnailURL string `json:"thumbnail_url"`
IsComplete bool `json:"is_complete"`
}
type adminBoxView struct {
ID string `json:"id"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
FileCount int `json:"file_count"`
CompleteFiles int `json:"complete_files"`
PendingFiles int `json:"pending_files"`
FailedFiles int `json:"failed_files"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"`
CreatedAtISO string `json:"created_at_iso"`
ExpiresAtLabel string `json:"expires_at_label"`
ExpiresAtISO string `json:"expires_at_iso"`
RetentionLabel string `json:"retention_label"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
ZipDisabled bool `json:"zip_disabled"`
ZipAvailable bool `json:"zip_available"`
Consumed bool `json:"consumed"`
HasManifest bool `json:"has_manifest"`
OpenURL string `json:"open_url"`
ZipURL string `json:"zip_url"`
Flags []string `json:"flags"`
Files []adminBoxFileView `json:"files"`
SearchText string `json:"search_text"`
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
boxes, err := app.listAdminBoxes()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load boxes")
return
}
ctx.HTML(http.StatusOK, "admin/boxes.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "boxes",
"Boxes": boxes,
"ZipDownloadsOn": app.config.ZipDownloadsEnabled,
})
}
func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
var request adminBoxesActionRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
return
}
if len(request.BoxIDs) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
return
}
switch request.Action {
case "delete", "expire", "bump":
default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
return
}
if request.Action == "bump" && request.DeltaSeconds <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
return
}
processed := 0
warnings := make([]string, 0)
for _, boxID := range request.BoxIDs {
if !boxstore.ValidBoxID(boxID) {
warnings = append(warnings, fmt.Sprintf("%s: invalid box id", boxID))
continue
}
var err error
switch request.Action {
case "delete":
err = boxstore.DeleteBox(boxID)
case "expire":
_, err = boxstore.ExpireBox(boxID)
case "bump":
_, err = boxstore.BumpBoxExpiry(boxID, time.Duration(request.DeltaSeconds)*time.Second)
}
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", boxID, err))
continue
}
processed++
}
boxes, err := app.listAdminBoxes()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Action finished, but boxes could not be reloaded"})
return
}
status := http.StatusOK
if processed == 0 && len(warnings) > 0 {
status = http.StatusBadRequest
}
ctx.JSON(status, gin.H{
"ok": len(warnings) == 0,
"message": adminBoxesActionMessage(request.Action, processed, request.DeltaSeconds),
"warnings": warnings,
"boxes": boxes,
})
}
func (app *App) listAdminBoxes() ([]adminBoxView, error) {
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list boxes")
return
return nil, err
}
rows := make([]adminBoxRow, 0, len(summaries))
totalSize := int64(0)
expiredCount := 0
boxes := make([]adminBoxView, 0, len(summaries))
for _, summary := range summaries {
totalSize += summary.TotalSize
if summary.Expired {
expiredCount++
boxView, err := app.buildAdminBoxView(summary.ID)
if err != nil {
continue
}
rows = append(rows, adminBoxRow{
boxes = append(boxes, boxView)
}
sort.Slice(boxes, func(i, j int) bool {
return boxes[i].CreatedAtISO > boxes[j].CreatedAtISO
})
return boxes, nil
}
func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
summary, err := boxstore.BoxSummary(boxID)
if err != nil {
return adminBoxView{}, err
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return adminBoxView{}, err
}
manifest, manifestErr := boxstore.ReadManifest(boxID)
hasManifest := manifestErr == nil
boxView := adminBoxView{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Expired: summary.Expired,
OneTimeDownload: summary.OneTimeDownload,
CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
CreatedAtISO: formatBrowserTime(summary.CreatedAt),
ExpiresAtLabel: "Not set",
ExpiresAtISO: formatBrowserTime(summary.ExpiresAt),
RetentionLabel: "Legacy / unmanaged",
PasswordProtected: summary.PasswordProtected,
})
OneTimeDownload: summary.OneTimeDownload,
HasManifest: hasManifest,
OpenURL: "/box/" + summary.ID,
Files: make([]adminBoxFileView, 0, len(files)),
}
ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{
"AdminSection": "boxes",
"CurrentUser": app.currentAdminUsername(ctx),
"Boxes": rows,
"TotalBoxes": len(rows),
"TotalStorage": helpers.FormatBytes(totalSize),
"ExpiredBoxes": expiredCount,
if !summary.ExpiresAt.IsZero() {
boxView.ExpiresAtLabel = adminTimeLabel(summary.ExpiresAt)
}
searchParts := []string{summary.ID, summary.TotalSizeLabel}
for _, file := range files {
if file.IsComplete {
boxView.CompleteFiles++
}
if file.Status == "failed" {
boxView.FailedFiles++
}
if !file.IsComplete && file.Status != "failed" {
boxView.PendingFiles++
}
boxView.Files = append(boxView.Files, adminBoxFileView{
Name: file.Name,
SizeLabel: file.SizeLabel,
MimeType: file.MimeType,
Status: file.Status,
StatusLabel: file.StatusLabel,
DownloadPath: file.DownloadPath,
ThumbnailURL: file.ThumbnailURL,
IsComplete: file.IsComplete,
})
searchParts = append(searchParts, file.Name, file.MimeType, file.StatusLabel)
}
if hasManifest {
boxView.RetentionLabel = manifest.RetentionLabel
boxView.ZipDisabled = manifest.DisableZip
boxView.Consumed = manifest.Consumed
} else {
boxView.ZipDisabled = false
}
boxView.ZipAvailable = app.config.ZipDownloadsEnabled && !boxView.ZipDisabled && !boxView.Consumed && boxView.FileCount > 0 && boxView.PendingFiles == 0
if boxView.ZipAvailable {
boxView.ZipURL = "/box/" + summary.ID + "/download"
}
boxView.Status, boxView.StatusLabel = deriveAdminBoxStatus(hasManifest, summary.Expired, boxView.PendingFiles, boxView.FailedFiles, boxView.Consumed)
boxView.Flags = deriveAdminBoxFlags(boxView)
searchParts = append(searchParts, boxView.StatusLabel, boxView.RetentionLabel)
boxView.SearchText = strings.ToLower(strings.Join(searchParts, " "))
return boxView, nil
}
func deriveAdminBoxStatus(hasManifest bool, expired bool, pendingFiles int, failedFiles int, consumed bool) (string, string) {
switch {
case !hasManifest:
return "legacy", "Legacy"
case consumed:
return "consumed", "Consumed"
case expired:
return "expired", "Expired"
case pendingFiles > 0:
return "uploading", "Uploading"
case failedFiles > 0:
return "attention", "Needs review"
default:
return "ready", "Ready"
}
}
func deriveAdminBoxFlags(box adminBoxView) []string {
flags := make([]string, 0, 5)
if box.PasswordProtected {
flags = append(flags, "protected")
}
if box.OneTimeDownload {
flags = append(flags, "one-time")
}
if box.ZipDisabled {
flags = append(flags, "zip off")
}
if !box.HasManifest {
flags = append(flags, "legacy")
}
if box.Consumed {
flags = append(flags, "consumed")
}
return flags
}
func adminTimeLabel(value time.Time) string {
if value.IsZero() {
return "Not set"
}
return value.UTC().Format("2006-01-02 15:04 UTC")
}
func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) string {
switch action {
case "delete":
return fmt.Sprintf("Deleted %d box(es)", processed)
case "expire":
return fmt.Sprintf("Expired %d box(es)", processed)
case "bump":
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
default:
return "Action complete"
}
}
func adminBoxesDeltaLabel(deltaSeconds int64) string {
switch deltaSeconds {
case 24 * 60 * 60:
return "24h"
case 7 * 24 * 60 * 60:
return "7d"
default:
return (time.Duration(deltaSeconds) * time.Second).String()
}
}

View File

@@ -1,14 +0,0 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) handleAdminDashboard(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "admin.html", gin.H{
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
})
}

View File

@@ -1,73 +0,0 @@
package server
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
func parseOptionalInt64(raw string) (*int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, errors.New("must be an integer")
}
if value < 0 {
return nil, errors.New("must be at least 0")
}
return &value, nil
}
func parseCSVInt64(raw string) ([]int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]int64, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
value, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds")
}
if value < 0 {
return nil, fmt.Errorf("allowed expiry durations must be at least 0")
}
values = append(values, value)
}
return values, nil
}
func optionalInt64Label(value *int64) string {
if value == nil {
return "-"
}
return strconv.FormatInt(*value, 10)
}
func joinInt64s(values []int64) string {
if len(values) == 0 {
return "-"
}
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ", ")
}
func formatAdminTime(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.Local().Format("2006-01-02 15:04:05")
}

View File

@@ -1,23 +0,0 @@
package server
import "github.com/gin-gonic/gin"
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin)
admin.POST("/login", app.handleAdminLoginPost)
protected := admin.Group("")
protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)
protected.GET("/tags", app.handleAdminTags)
protected.POST("/tags", app.handleAdminTagsPost)
protected.GET("/settings", app.handleAdminSettings)
protected.POST("/settings", app.handleAdminSettingsPost)
}

View File

@@ -1,58 +1,471 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
type adminSettingsCategoryView struct {
Key string
Label string
Icon string
Count int
Rows []adminSettingRowView
}
type adminSettingRowView struct {
Key string `json:"key"`
Label string `json:"label"`
EnvName string `json:"env_name"`
Category string `json:"category"`
CategoryLabel string `json:"category_label"`
Type string `json:"type"`
Value string `json:"value"`
DefaultValue string `json:"default_value"`
Source string `json:"source"`
SourceBadge string `json:"source_badge"`
Editable bool `json:"editable"`
Locked bool `json:"locked"`
HardLimit bool `json:"hard_limit"`
Minimum int64 `json:"minimum"`
Description string `json:"description"`
}
type adminSettingsSaveRequest struct {
Values map[string]string `json:"values"`
}
type adminSettingsImportRequest struct {
Settings map[string]string `json:"settings"`
EditableSettings map[string]string `json:"editable_settings"`
Values map[string]string `json:"values"`
Changes map[string]string `json:"changes"`
}
type adminSettingsResetRequest struct {
Keys []string `json:"keys"`
}
type adminSettingsExportResponse struct {
Format string `json:"format"`
ExportedAt string `json:"exported_at"`
Settings map[string]string `json:"settings"`
EditableSettings map[string]string `json:"editable_settings"`
Rows []adminSettingRowView `json:"rows"`
}
func (app *App) handleAdminSettings(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
app.renderAdminSettings(ctx, "")
}
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
return
}
if !app.config.AllowAdminSettingsOverride {
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
return
}
for _, def := range config.EditableDefinitions() {
value := ctx.PostForm(def.Key)
if def.Type == config.SettingTypeBool {
value = "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
}
if err := app.config.ApplyOverride(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
if err := app.store.SetSetting(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
}
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
}
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"AdminSection": "settings",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage,
rows, categories := app.buildAdminSettingsRows()
ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "settings",
"Rows": rows,
"Categories": categories,
"RowsJSON": rows,
})
}
func (app *App) handleAdminSettingsExport(ctx *gin.Context) {
rows, _ := app.buildAdminSettingsRows()
ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows))
}
func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
var request adminSettingsSaveRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
return
}
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
return
}
if currentOverrides == nil {
currentOverrides = map[string]string{}
}
for key, value := range request.Values {
currentOverrides[key] = value
}
rows, warnings, err := app.applySettingsOverrideSet(currentOverrides)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)),
"warnings": warnings,
"rows": rows,
})
}
func (app *App) handleAdminSettingsImport(ctx *gin.Context) {
var request adminSettingsImportRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"})
return
}
values := request.Values
if len(values) == 0 {
values = request.Settings
}
if len(values) == 0 {
values = request.EditableSettings
}
if len(values) == 0 {
values = request.Changes
}
if len(values) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"})
return
}
editable := map[string]bool{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = true
}
filtered := make(map[string]string, len(values))
warnings := make([]string, 0)
for key, value := range values {
if editable[key] {
filtered[key] = value
continue
}
if _, found := config.Definition(key); found {
warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key))
continue
}
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
}
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
return
}
for key, value := range currentOverrides {
if _, exists := filtered[key]; !exists {
filtered[key] = value
}
}
rows, applyWarnings, err := app.applySettingsOverrideSet(filtered)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
warnings = append(warnings, applyWarnings...)
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": fmt.Sprintf("Imported %d setting value(s)", len(values)),
"warnings": warnings,
"rows": rows,
})
}
func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
var request adminSettingsResetRequest
_ = ctx.ShouldBindJSON(&request)
overrideSet, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
return
}
if overrideSet == nil {
overrideSet = map[string]string{}
}
targetKeys := map[string]bool{}
for _, key := range request.Keys {
targetKeys[config.NormalizeLegacySettingKey(key)] = true
}
if len(targetKeys) == 0 {
overrideSet = map[string]string{}
} else {
for key := range targetKeys {
delete(overrideSet, key)
}
}
rows, warnings, err := app.applySettingsOverrideSet(overrideSet)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": "Selected overrides cleared; environment and defaults now apply",
"warnings": warnings,
"rows": rows,
})
}
func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) {
if !app.config.AllowAdminSettingsOverride {
return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment")
}
if values == nil {
values = map[string]string{}
}
overrideSet := make(map[string]string, len(values))
warnings := make([]string, 0)
editable := map[string]config.SettingDefinition{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = def
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
normalizedKey, normalizedValue, err := config.NormalizeOverrideInput(key, strings.TrimSpace(values[key]))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", key, err)
}
key = normalizedKey
value := normalizedValue
def, ok := editable[key]
if !ok {
if _, found := config.Definition(key); found {
return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key)
}
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
continue
}
if value == "" && def.Type != config.SettingTypeText {
return nil, nil, fmt.Errorf("setting %q cannot be blank", key)
}
overrideSet[key] = value
}
nextCfg, err := config.Load()
if err != nil {
return nil, nil, err
}
if err := nextCfg.ApplyOverrides(overrideSet); err != nil {
return nil, nil, err
}
if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil {
return nil, nil, err
}
app.config = nextCfg
applyBoxstoreRuntimeConfig(app.config)
rows, _ := app.buildAdminSettingsRows()
return rows, warnings, nil
}
func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse {
settings := make(map[string]string, len(rows))
editable := make(map[string]string)
for _, row := range rows {
settings[row.Key] = row.Value
if row.Editable && !row.Locked {
editable[row.Key] = row.Value
}
}
return adminSettingsExportResponse{
Format: "warpbox.settings.export.v1",
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Settings: settings,
EditableSettings: editable,
Rows: rows,
}
}
func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) {
cfgRows := app.config.SettingRows()
rows := make([]adminSettingRowView, 0, len(cfgRows)+5)
for _, row := range cfgRows {
rows = append(rows, app.makeDefinitionSettingRow(row))
}
rows = append(rows,
app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."),
app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."),
app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."),
app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."),
app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."),
)
sort.Slice(rows, func(i, j int) bool {
if rows[i].Category == rows[j].Category {
return rows[i].Label < rows[j].Label
}
return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category)
})
categoryMeta := settingsCategoryMeta()
categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1)
allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)}
categories = append(categories, allCategory)
grouped := map[string][]adminSettingRowView{}
for _, row := range rows {
grouped[row.Category] = append(grouped[row.Category], row)
}
for _, meta := range categoryMeta {
categories = append(categories, adminSettingsCategoryView{
Key: meta.Key,
Label: meta.Label,
Icon: meta.Icon,
Count: len(grouped[meta.Key]),
Rows: grouped[meta.Key],
})
}
return rows, categories
}
func boolString(value bool) string {
if value {
return "true"
}
return "false"
}
func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView {
def := row.Definition
locked := !def.Editable || def.HardLimit
source := string(row.Source)
sourceBadge := source
if locked {
sourceBadge = "hard env"
}
return adminSettingRowView{
Key: def.Key,
Label: def.Label,
EnvName: def.EnvName,
Category: settingsCategoryForKey(def.Key),
CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)),
Type: string(def.Type),
Value: row.Value,
DefaultValue: app.config.DefaultValue(def.Key),
Source: source,
SourceBadge: sourceBadge,
Editable: def.Editable && !def.HardLimit,
Locked: locked,
HardLimit: def.HardLimit,
Minimum: def.Minimum,
Description: settingsDescription(def.Key),
}
}
func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView {
return adminSettingRowView{
Key: key,
Label: label,
EnvName: envName,
Category: category,
CategoryLabel: settingsCategoryLabel(category),
Type: rowType,
Value: value,
DefaultValue: "",
Source: "environment",
SourceBadge: "hard env",
Editable: false,
Locked: true,
HardLimit: true,
Description: description,
}
}
type settingsCategoryInfo struct {
Key string
Label string
Icon string
}
func settingsCategoryMeta() []settingsCategoryInfo {
return []settingsCategoryInfo{
{Key: "uploads", Label: "Uploads", Icon: "↥"},
{Key: "downloads", Label: "Downloads", Icon: "↧"},
{Key: "retention", Label: "Retention", Icon: "⌛"},
{Key: "accounts", Label: "Accounts", Icon: "☺"},
{Key: "api", Label: "API", Icon: "{ }"},
{Key: "storage", Label: "Storage", Icon: "▥"},
{Key: "workers", Label: "Workers", Icon: "⚙"},
}
}
func settingsCategoryLabel(key string) string {
for _, meta := range settingsCategoryMeta() {
if meta.Key == key {
return meta.Label
}
}
return "General"
}
func settingsCategoryRank(key string) int {
for index, meta := range settingsCategoryMeta() {
if meta.Key == key {
return index
}
}
return len(settingsCategoryMeta()) + 1
}
func settingsCategoryForKey(key string) string {
switch key {
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
return "uploads"
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
return "downloads"
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
return "retention"
case config.SettingSessionTTLSeconds:
return "accounts"
case config.SettingAPIEnabled:
return "api"
case config.SettingDataDir:
return "storage"
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
return "workers"
default:
return "accounts"
}
}
func settingsDescription(key string) string {
descriptions := map[string]string{
config.SettingGuestUploadsEnabled: "Allow unauthenticated guests to create boxes through the public upload flow.",
config.SettingAPIEnabled: "Enable API endpoints used by the browser upload and status workflows.",
config.SettingZipDownloadsEnabled: "Allow archive downloads for full boxes when ZIP is supported.",
config.SettingOneTimeDownloadsEnabled: "Enable one-time download retention mode for boxes.",
config.SettingOneTimeDownloadExpirySecs: "Expiry window, in seconds, for one-time download boxes after upload completion.",
config.SettingOneTimeDownloadRetryFail: "When enabled by environment, failed one-time ZIP writes leave the box retryable.",
config.SettingRenewOnAccessEnabled: "Extend retention when a box page is viewed.",
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling in GB applied to future requests across the whole app. Decimal values allowed.",
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling in GB used by future account-aware flows. Decimal values allowed.",
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling in GB used by future account-aware flows. Decimal values allowed.",
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
}
return descriptions[key]
}

View File

@@ -0,0 +1,271 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
)
func TestAdminSettingsRequiresAuth(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/admin/login" {
t.Fatalf("expected login redirect, got %q", location)
}
if app == nil {
t.Fatal("expected app setup")
}
}
func TestAdminSettingsPageRenders(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "WarpBox Settings") {
t.Fatalf("expected settings page title, got %s", body)
}
if !strings.Contains(body, "WARPBOX_API_ENABLED") {
t.Fatalf("expected API env var in page body")
}
}
func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", response.Code)
}
var payload struct {
Format string `json:"format"`
Settings map[string]string `json:"settings"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload.Format != "warpbox.settings.export.v1" {
t.Fatalf("unexpected export format: %q", payload.Format)
}
if payload.Settings[config.SettingAPIEnabled] != "false" {
t.Fatalf("expected api_enabled to reflect environment false, got %q", payload.Settings[config.SettingAPIEnabled])
}
}
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000","global_max_file_size_gb":"0.5"}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
if !app.config.APIEnabled {
t.Fatal("expected APIEnabled override to be applied")
}
if app.config.BoxPollIntervalMS != 6000 {
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
}
if app.config.GlobalMaxFileSizeBytes != 512*1024*1024 {
t.Fatalf("expected size override in bytes, got %d", app.config.GlobalMaxFileSizeBytes)
}
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
t.Fatalf("ReadAdminSettingsOverrides returned error: %v", err)
}
if overrides[config.SettingAPIEnabled] != "true" {
t.Fatalf("expected persisted API override, got %#v", overrides)
}
if _, exists := overrides[config.SettingBoxPollIntervalMS]; !exists {
t.Fatalf("expected changed poll interval override to be persisted, got %#v", overrides)
}
if _, exists := overrides[config.SettingSessionTTLSeconds]; exists {
t.Fatalf("expected untouched setting to stay out of overrides, got %#v", overrides)
}
}
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"data_dir":"./other"}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", response.Code)
}
}
func TestAdminSettingsImportSkipsLockedAndUnknownKeys(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/import", strings.NewReader(`{"settings":{"api_enabled":"true","data_dir":"./other","bogus":"x"}}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
if !app.config.APIEnabled {
t.Fatal("expected editable import value to apply")
}
var payload struct {
Warnings []string `json:"warnings"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if len(payload.Warnings) != 2 {
t.Fatalf("expected 2 warnings, got %#v", payload.Warnings)
}
}
func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/reset", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
request.AddCookie(authCookie(app))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
if app.config.APIEnabled {
t.Fatal("expected reset to respect environment and restore APIEnabled=false")
}
}
func setupAdminSettingsTest(t *testing.T) (*App, *gin.Engine) {
t.Helper()
gin.SetMode(gin.TestMode)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd returned error: %v", err)
}
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
if err := os.Chdir(root); err != nil {
t.Fatalf("Chdir returned error: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(cwd)
})
clearAdminSettingsEnv(t)
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
t.Setenv("WARPBOX_API_ENABLED", "false")
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if err := cfg.EnsureDirectories(); err != nil {
t.Fatalf("EnsureDirectories returned error: %v", err)
}
app := &App{
config: cfg,
settingsOverridesPath: filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename),
}
htmlTemplates, err := loadHTMLTemplates()
if err != nil {
t.Fatalf("loadHTMLTemplates returned error: %v", err)
}
router := gin.New()
router.SetHTMLTemplate(htmlTemplates)
admin := router.Group("/admin")
admin.GET("/login", app.handleAdminLogin)
protected := router.Group("/admin", app.adminAuthMiddleware)
protected.GET("/settings", app.handleAdminSettings)
protected.GET("/settings/export", app.handleAdminSettingsExport)
protected.POST("/settings/save", app.handleAdminSettingsSave)
protected.POST("/settings/import", app.handleAdminSettingsImport)
protected.POST("/settings/reset", app.handleAdminSettingsReset)
return app, router
}
func authCookie(app *App) *http.Cookie {
return &http.Cookie{Name: adminSessionCookie, Value: app.adminSessionToken()}
}
func clearAdminSettingsEnv(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_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS",
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
"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_GB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_GB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB",
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
"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, "")
}
}

View File

@@ -1,122 +0,0 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type adminTagRow struct {
ID string
Name string
Description string
Protected bool
AdminAccess bool
UploadAllowed bool
ZipDownloadAllowed bool
OneTimeDownloadAllowed bool
RenewableAllowed bool
MaxFileSizeBytes string
MaxBoxSizeBytes string
AllowedExpirySeconds string
}
func (app *App) handleAdminTags(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminTags(ctx, "")
}
func (app *App) handleAdminTagsPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
perms, err := parseTagPermissions(ctx)
if err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
tag := metastore.Tag{
Name: ctx.PostForm("name"),
Description: ctx.PostForm("description"),
Permissions: perms,
}
if err := app.store.CreateTag(&tag); err != nil {
app.renderAdminTags(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/tags")
}
func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) {
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
sort.Slice(tags, func(i int, j int) bool {
return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name)
})
rows := make([]adminTagRow, 0, len(tags))
for _, tag := range tags {
rows = append(rows, adminTagRow{
ID: tag.ID,
Name: tag.Name,
Description: tag.Description,
Protected: tag.Protected,
AdminAccess: tag.Permissions.AdminAccess,
UploadAllowed: tag.Permissions.UploadAllowed,
ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed,
OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed,
RenewableAllowed: tag.Permissions.RenewableAllowed,
MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes),
MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes),
AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds),
})
}
ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{
"AdminSection": "tags",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Tags": rows,
"Error": errorMessage,
})
}
func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) {
maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err)
}
maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes"))
if err != nil {
return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err)
}
expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds"))
if err != nil {
return metastore.TagPermissions{}, err
}
return metastore.TagPermissions{
UploadAllowed: checkbox(ctx, "upload_allowed"),
AllowedExpirySeconds: expirySeconds,
MaxFileSizeBytes: maxFileSize,
MaxBoxSizeBytes: maxBoxSize,
OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"),
ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"),
RenewableAllowed: checkbox(ctx, "renewable_allowed"),
AdminAccess: checkbox(ctx, "admin_access"),
AdminUsersManage: checkbox(ctx, "admin_users_manage"),
AdminSettingsManage: checkbox(ctx, "admin_settings_manage"),
AdminBoxesView: checkbox(ctx, "admin_boxes_view"),
}, nil
}
func checkbox(ctx *gin.Context, name string) bool {
return ctx.PostForm(name) == "true"
}

View File

@@ -2,120 +2,19 @@ package server
import (
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type adminUserRow struct {
ID string
Username string
Email string
Tags string
CreatedAt string
Disabled bool
IsCurrent bool
}
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
return
}
app.renderAdminUsers(ctx, "")
}
func (app *App) handleAdminUsersPost(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
if ctx.PostForm("action") == "toggle_disabled" {
userID := strings.TrimSpace(ctx.PostForm("user_id"))
user, ok, err := app.store.GetUser(userID)
if err != nil || !ok {
app.renderAdminUsers(ctx, "User not found.")
return
}
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID {
app.renderAdminUsers(ctx, "You cannot disable the user for the active session.")
return
}
}
user.Disabled = !user.Disabled
if err := app.store.UpdateUser(user); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
return
}
username := ctx.PostForm("username")
email := ctx.PostForm("email")
password := ctx.PostForm("password")
tagIDs := ctx.PostFormArray("tag_ids")
if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil {
app.renderAdminUsers(ctx, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/admin/users")
}
func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) {
users, err := app.store.ListUsers()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list users")
return
}
tags, err := app.store.ListTags()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not list tags")
return
}
tagNames := make(map[string]string, len(tags))
for _, tag := range tags {
tagNames[tag.ID] = tag.Name
}
sort.Slice(users, func(i int, j int) bool {
return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username)
})
currentID := ""
if current, ok := ctx.Get("adminUser"); ok {
if currentUser, ok := current.(metastore.User); ok {
currentID = currentUser.ID
}
}
rows := make([]adminUserRow, 0, len(users))
for _, user := range users {
names := make([]string, 0, len(user.TagIDs))
for _, tagID := range user.TagIDs {
if name := tagNames[tagID]; name != "" {
names = append(names, name)
}
}
rows = append(rows, adminUserRow{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Tags: strings.Join(names, ", "),
CreatedAt: formatAdminTime(user.CreatedAt),
Disabled: user.Disabled,
IsCurrent: user.ID == currentID,
})
}
ctx.HTML(http.StatusOK, "admin_users.html", gin.H{
"AdminSection": "users",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Users": rows,
"Tags": tags,
"Error": errorMessage,
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "users",
})
}

View File

@@ -1,17 +1,12 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
@@ -40,40 +35,3 @@ func TestValidateManifestFileUploadRejectsExpiredBox(t *testing.T) {
t.Fatalf("expected expired box to be deleted, stat err=%v", err)
}
}
func TestAdminProtectedPostRequiresCSRF(t *testing.T) {
gin.SetMode(gin.TestMode)
store, err := metastore.Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
adminTag, err := store.EnsureAdminTag()
if err != nil {
t.Fatalf("EnsureAdminTag returned error: %v", err)
}
user, err := store.CreateUserWithPassword("admin", "", "secret", []string{adminTag.ID})
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
session, err := store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
app := &App{config: &config.Config{}, store: store}
router := gin.New()
router.POST("/admin/test", app.requireAdminSession, func(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPost, "/admin/test", nil)
request.AddCookie(&http.Cookie{Name: adminSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected missing CSRF token to be forbidden, got %d", response.Code)
}
}

View File

@@ -1,7 +1,9 @@
package server
import (
"fmt"
"encoding/json"
"html/template"
"path/filepath"
"time"
"github.com/gin-contrib/gzip"
@@ -9,14 +11,12 @@ import (
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/routing"
)
type App struct {
config *config.Config
store *metastore.Store
adminLoginEnabled bool
settingsOverridesPath string
}
func Run(addr string) error {
@@ -27,39 +27,28 @@ func Run(addr string) error {
if err := cfg.EnsureDirectories(); err != nil {
return err
}
applyBoxstoreRuntimeConfig(cfg)
store, err := metastore.Open(cfg.DBDir)
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
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)
return err
}
if err := cfg.ApplyOverrides(overrides); err != nil {
return fmt.Errorf("apply settings overrides: %w", err)
return err
}
applyBoxstoreRuntimeConfig(cfg)
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil {
return fmt.Errorf("bootstrap admin metadata: %w", err)
}
app := &App{
config: cfg,
store: store,
adminLoginEnabled: bootstrap.AdminLoginEnabled,
}
app := &App{config: cfg, settingsOverridesPath: overridesPath}
router := gin.Default()
router.LoadHTMLGlob("templates/*.html")
htmlTemplates, err := loadHTMLTemplates()
if err != nil {
return err
}
router.SetHTMLTemplate(htmlTemplates)
routing.Register(router, routing.Handlers{
Health: app.handleHealth,
Index: app.handleIndex,
ShowBox: app.handleShowBox,
BoxLogin: handleBoxLogin,
@@ -73,8 +62,22 @@ func Run(addr string) error {
FileStatusUpdate: app.handleFileStatusUpdate,
DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload,
AdminLogin: app.handleAdminLogin,
AdminLoginPost: app.handleAdminLoginPost,
AdminLogout: app.handleAdminLogout,
AdminDashboard: app.handleAdminDashboard,
AdminAlerts: app.handleAdminAlerts,
AdminBoxes: app.handleAdminBoxes,
AdminBoxesAction: app.handleAdminBoxesAction,
AdminUsers: app.handleAdminUsers,
AdminSettings: app.handleAdminSettings,
AdminSettingsExport: app.handleAdminSettingsExport,
AdminSettingsSave: app.handleAdminSettingsSave,
AdminSettingsImport: app.handleAdminSettingsImport,
AdminSettingsReset: app.handleAdminSettingsReset,
AdminAuth: app.adminAuthMiddleware,
})
app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static")
@@ -84,7 +87,37 @@ func Run(addr string) error {
return router.Run(addr)
}
func loadHTMLTemplates() (*template.Template, error) {
tmpl := template.New("").Funcs(template.FuncMap{
"toJSON": func(value any) template.JS {
data, err := json.Marshal(value)
if err != nil {
return template.JS("null")
}
return template.JS(data)
},
})
for _, pattern := range []string{
"templates/*.html",
"templates/admin/*.html",
"templates/admin/partials/*.html",
} {
var err error
tmpl, err = tmpl.ParseGlob(pattern)
if err != nil {
return nil, err
}
}
return tmpl, nil
}
func applyBoxstoreRuntimeConfig(cfg *config.Config) {
boxstore.SetUploadRoot(cfg.UploadsDir)
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
}
func (app *App) handleHealth(c *gin.Context) {
c.JSON(200, gin.H{
"status": "healthy",
})
}

6
run.sh
View File

@@ -15,9 +15,9 @@ export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXP
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}"
# Storage and expiry limits used by the upload UI and backend validators.
# Use megabytes here; WarpBox converts these to bytes internally.
export WARPBOX_GLOBAL_MAX_FILE_SIZE_MB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_MB:-2048}" # 2 GiB
export WARPBOX_GLOBAL_MAX_BOX_SIZE_MB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_MB:-4096}" # 4 GiB
# Use decimal gigabytes here. Examples: 2, 4, 0.5
export WARPBOX_GLOBAL_MAX_FILE_SIZE_GB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_GB:-2}" # 2 GB
export WARPBOX_GLOBAL_MAX_BOX_SIZE_GB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_GB:-4}" # 4 GB
export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour
export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours

View File

@@ -1,132 +1,738 @@
body {
/* ===========================
Admin Shell / Frame
=========================== */
.admin-shell {
width: 100%;
min-height: 100vh;
}
.admin-window {
width: min(1120px, calc(100vw - 32px));
margin: 32px auto;
}
.admin-panel {
display: grid;
gap: 16px;
padding: 16px;
background-color: #ffffff;
background-image:
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
}
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-start;
align-items: center;
flex-direction: column;
padding: 10px 16px 34px;
gap: 10px;
}
.admin-spacer {
flex: 1;
}
.admin-grid {
.admin-frame {
width: min(var(--admin-frame-width, 1320px), 100%);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
grid-template-rows: auto auto;
gap: 10px;
align-items: start;
}
.admin-link {
min-height: 88px;
padding: 12px;
color: inherit;
/* ===========================
Admin Taskbar (top nav)
=========================== */
.admin-taskbar {
width: 100%;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
color: #000000;
background-color: var(--w98-gray);
background-image: linear-gradient(180deg, rgba(255,255,255,.36), rgba(0,0,0,.08)), repeating-linear-gradient(45deg, rgba(255,255,255,.12) 0 1px, transparent 1px 5px);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 4px 4px 0 rgba(0,0,0,.45);
padding: 3px;
position: sticky;
top: 0;
z-index: 50;
transition: box-shadow 120ms steps(2, end), filter 120ms steps(2, end);
}
.admin-taskbar.is-scrolled {
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 5px 0 rgba(0,0,0,.55), 0 11px 0 rgba(0,0,0,.18);
filter: brightness(1.02);
}
.admin-taskbar.is-scrolled::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -10px;
height: 10px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0,0,0,.46), rgba(0,0,0,0));
}
/* ===========================
Start Button
=========================== */
.admin-start-button {
min-width: 108px;
height: 24px;
display: inline-grid;
grid-template-columns: 18px 1fr;
align-items: center;
gap: 5px;
padding: 0 8px;
color: #000000;
background: var(--w98-gray);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-weight: bold;
text-decoration: none;
background: #dfdfdf;
white-space: nowrap;
}
.admin-start-button:active {
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
padding-top: 1px;
}
.admin-start-logo {
width: 16px;
height: 16px;
display: grid;
place-items: center;
color: #ffffff;
background: #000078;
border: 1px solid #ffffff;
box-shadow: inset -5px 0 0 #0f80cd, inset 0 -5px 0 #4c1ca0;
font-size: 10px;
line-height: 10px;
}
/* ===========================
Taskbar Nav Buttons
=========================== */
.admin-taskbar-nav {
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 1px;
}
.admin-taskbar-button {
height: 24px;
min-width: 76px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 8px;
color: #000000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
text-decoration: none;
white-space: nowrap;
}
.admin-link strong,
.admin-link span {
.admin-taskbar-button:active {
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080;
padding-top: 1px;
}
.admin-taskbar-button.is-active {
color: #ffffff;
background: #000078;
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
}
.admin-taskbar-button:hover {
color: #ffffff;
background: #000078;
}
/* ===========================
Taskbar Session Chips
=========================== */
.admin-taskbar-session {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 5px;
white-space: nowrap;
}
.admin-session-chip,
.admin-alert-chip {
height: 24px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 8px;
background: #dfdfdf;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
color: #000000;
text-decoration: none;
white-space: nowrap;
}
.admin-alert-chip.is-ok { background: #e8ffe8; border-color: #008000 #ffffff #ffffff #008000; }
.admin-alert-chip.is-info { background: #d8e5f8; }
.admin-alert-chip.is-warning {
background: #ffffcc;
border: 3px solid transparent;
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
}
.admin-alert-chip.is-danger {
color: #ffffff;
background: #800000;
border: 3px solid transparent;
border-image: repeating-linear-gradient(45deg, #ffcccc 0 8px, #300000 8px 16px) 3;
}
/* ===========================
Dashboard Window
=========================== */
.admin-dashboard-window,
.admin-workspace-window {
width: 100%;
min-height: 0;
padding: 0;
overflow: visible;
color: #000000;
background-color: var(--w98-gray);
background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06));
}
.admin-dashboard-window > .win98-titlebar,
.admin-workspace-window > .win98-titlebar {
margin: 2px 2px 0;
}
.admin-dashboard-window > .menu-bar,
.admin-workspace-window > .menu-bar {
flex: 0 0 auto;
height: auto;
min-height: 24px;
margin: 0 2px;
padding: 1px 6px;
color: #000000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
z-index: 30;
}
.admin-dashboard-window > .menu-bar .menu-button,
.admin-workspace-window > .menu-bar .menu-button {
color: #000000;
}
.admin-dashboard-window > .dashboard-body,
.admin-workspace-window > .admin-workspace-body {
flex: 1 1 auto;
margin-top: 0;
padding: 0 10px 10px;
background-color: var(--w98-gray);
background-image: linear-gradient(180deg, rgba(255,255,255,.18), rgba(0,0,0,.05));
}
.admin-dashboard-statusbar {
grid-template-columns: minmax(0, 1fr) 160px 210px;
height: 28px;
padding: 3px 4px 4px;
background: var(--w98-gray);
font-size: 12px;
line-height: 14px;
}
.admin-dashboard-statusbar span {
min-height: 19px;
align-items: center;
padding: 1px 6px;
}
/* ===========================
Menu Bar (toolbar)
=========================== */
.admin-menu-bar {
position: relative;
display: flex;
align-items: center;
gap: 2px;
min-height: 24px;
padding: 1px 6px;
font-size: 13px;
line-height: 13px;
z-index: 20;
}
.admin-menu-item {
position: relative;
}
.admin-menu-button {
height: 20px;
min-width: 54px;
padding: 0 8px;
color: #000000;
background: transparent;
border: 1px solid transparent;
font-family: inherit;
font-size: 13px;
text-align: left;
}
.admin-menu-button:hover,
.admin-menu-button:focus-visible {
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
outline: none;
}
.admin-menu-popup {
position: absolute;
top: 22px;
left: 0;
min-width: 220px;
padding: 2px;
background: var(--w98-gray);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: 3px 3px 0 rgba(0,0,0,.35);
display: none;
z-index: 60;
}
.admin-menu-item.is-open .admin-menu-popup {
display: block;
}
.admin-link span {
margin-top: 8px;
}
.admin-table {
.admin-menu-action {
width: 100%;
border-collapse: collapse;
background: #fff;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.admin-table th,
.admin-table td {
padding: 8px;
border: 1px solid #808080;
text-align: left;
vertical-align: top;
}
.admin-form {
min-height: 22px;
display: grid;
gap: 10px;
grid-template-columns: 20px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 2px 6px;
color: #000000;
background: transparent;
border: 0;
font-family: inherit;
font-size: 12px;
text-align: left;
}
.admin-form-row {
.admin-menu-action:hover,
.admin-menu-action:focus-visible {
color: #ffffff;
background: #000078;
outline: none;
}
.admin-menu-separator {
height: 1px;
margin: 3px 2px;
background: #808080;
border-bottom: 1px solid #ffffff;
}
.admin-menu-action .shortcut {
color: #555555;
}
.admin-menu-action:hover .shortcut {
color: #ffffff;
}
/* ===========================
Hero Section
=========================== */
.admin-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 10px;
padding: 9px;
align-items: stretch;
}
.admin-hero-copy h2 {
margin: 0 0 5px;
font-size: 22px;
line-height: 24px;
}
.admin-hero-copy p {
margin: 0;
color: #333333;
font-size: 13px;
line-height: 15px;
}
.admin-hero-status {
display: grid;
gap: 4px;
}
.admin-form-row input,
.admin-form-row textarea,
.admin-form-row select {
width: 100%;
min-height: 24px;
color: #000000;
align-content: center;
padding: 7px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-family: inherit;
font-size: 12px;
line-height: 13px;
}
.admin-checks {
.admin-hero-status-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.admin-status-ok { color: #008000; }
.admin-status-warn { color: #8a6200; }
.admin-status-danger { color: #800000; }
/* ===========================
Stats Grid
=========================== */
.admin-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.admin-checks label {
display: flex;
gap: 6px;
align-items: center;
}
.admin-error {
padding: 8px;
border: 1px solid #800;
background: #ffdede;
}
.admin-summary {
.admin-stat-card {
position: relative;
min-height: 122px;
padding: 10px 11px 10px 14px;
overflow: hidden;
}
/* Left accent bar */
.admin-stat-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 7px;
border-left: 7px solid #000078;
pointer-events: none;
}
/* Severity color states */
.admin-stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
.admin-stat-card.is-ok::before { border-left-color: #008000; }
.admin-stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
.admin-stat-card.is-info::before { border-left-color: #000078; }
.admin-stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
.admin-stat-card.is-warning::before { border-left-color: #ffcc00; }
.admin-stat-card.is-danger {
color: #000000;
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
}
.admin-stat-card.is-danger::before { border-left-color: #800000; }
.admin-stat-label {
margin: 0 0 6px;
color: #333333;
font-size: 13px;
line-height: 13px;
font-weight: bold;
}
.admin-stat-value {
margin: 0 0 7px;
font-size: 32px;
line-height: 32px;
font-weight: bold;
}
.admin-stat-note {
display: flex;
gap: 4px;
flex-wrap: wrap;
gap: 8px;
margin: 0;
color: #222222;
font-size: 12px;
line-height: 14px;
}
.admin-summary span {
padding: 6px 8px;
.admin-stat-note-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
white-space: nowrap;
}
/* ===========================
Main Grid / Section Windows
=========================== */
.admin-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.admin-span-2 {
grid-column: 1 / -1;
}
.admin-section-window {
min-height: 0;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38);
}
.admin-section-body {
margin: 0 6px 6px;
padding: 8px;
min-height: 0;
}
/* ===========================
Quick Actions
=========================== */
.admin-link-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 6px;
}
.admin-link-list li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
color: #000000;
font-size: 13px;
line-height: 13px;
}
.admin-link-button {
min-width: 112px;
height: 24px;
display: inline-grid;
place-items: center;
padding: 0 10px;
color: #000000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-size: 12px;
line-height: 12px;
text-decoration: none;
}
.admin-link-button:hover {
filter: brightness(1.06);
}
/* Titlebar action links (Show all) */
.titlebar-actions {
display: flex;
align-items: center;
gap: 2px;
margin-left: 8px;
}
.titlebar-link-button {
height: 18px;
min-width: 64px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 7px;
color: #000000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
text-decoration: none;
font-size: 12px;
line-height: 12px;
white-space: nowrap;
}
.titlebar-link-button:hover {
filter: brightness(1.08);
}
/* ===========================
Compact Mode
=========================== */
body.is-compact .admin-dashboard-body {
gap: 8px;
}
body.is-compact .admin-section-body {
padding: 5px;
}
/* ===========================
Responsive: Medium (tablets)
=========================== */
@media (max-width: 1180px) {
.admin-taskbar {
grid-template-columns: auto minmax(0, 1fr);
}
.admin-taskbar-session {
grid-column: 1 / -1;
justify-content: flex-start;
overflow-x: auto;
}
.admin-stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-hero {
grid-template-columns: 1fr;
}
.admin-main-grid {
grid-template-columns: 1fr;
}
.admin-span-2 {
grid-column: auto;
}
}
/* ===========================
Responsive: Small (mobile)
=========================== */
@media (max-width: 760px) {
.admin-shell {
padding: 0 0 18px;
align-items: stretch;
}
.admin-frame {
width: 100%;
gap: 8px;
}
.admin-taskbar {
grid-template-columns: 1fr;
border-left: 0;
border-right: 0;
box-shadow: none;
}
.admin-start-button {
width: 100%;
justify-content: center;
}
.admin-taskbar-nav {
width: 100%;
overflow-x: auto;
padding-bottom: 3px;
}
.admin-taskbar-button {
min-width: 92px;
}
.admin-taskbar-session {
width: 100%;
overflow-x: auto;
padding-bottom: 3px;
}
.admin-session-chip,
.admin-alert-chip {
flex: 0 0 auto;
}
.admin-dashboard-window,
.admin-workspace-window {
min-height: 100dvh;
border-left: 0;
border-right: 0;
box-shadow: none;
}
.admin-dashboard-body {
padding: 6px;
gap: 8px;
}
.admin-stats-grid {
grid-template-columns: 1fr;
}
.admin-stat-card {
min-height: 112px;
}
.admin-menu-popup {
position: fixed;
left: 6px;
right: 6px;
top: 74px;
min-width: 0;
}
.titlebar-actions {
margin-left: 4px;
}
.titlebar-link-button {
min-width: 58px;
padding: 0 5px;
}
.admin-dashboard-statusbar {
grid-template-columns: 1fr;
height: auto;
min-height: 70px;
}
.win98-titlebar h1,
.win98-titlebar h2 {
font-size: 13px;
}
.win98-window-controls {
display: none;
}
}
/* Override global main layout on admin pages since admin uses its own shell */
body:has(.admin-shell) main {
display: contents;
}

394
static/css/alerts.css Normal file
View File

@@ -0,0 +1,394 @@
.alerts-page-body {
display: grid;
gap: 10px;
}
.alerts-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.alerts-stat-card {
min-width: 0;
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
}
.alerts-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.alerts-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.alerts-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.alerts-stat-label {
margin: 0 0 4px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
color: #333333;
}
.alerts-stat-value {
margin: 0;
font-size: 24px;
line-height: 24px;
font-weight: bold;
}
.alerts-stat-note {
margin: 6px 0 0;
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: rgba(255,255,255,.65);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a0a0a0;
border-bottom: 1px solid #a0a0a0;
font-size: 12px;
line-height: 12px;
}
.alerts-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr);
gap: 10px;
min-height: 0;
}
.alerts-column {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.alerts-list-panel {
flex: 1 1 auto;
min-height: 520px;
}
.alerts-actions-panel {
flex: 1 1 auto;
min-height: 220px;
}
.alerts-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
}
.alerts-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 1px 1px 0 #f7f7f7;
}
.alerts-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 22px;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.alerts-panel-sub {
color: #444444;
font-size: 12px;
line-height: 12px;
font-weight: normal;
}
.alerts-panel-tools {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.alerts-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
overflow: auto;
background-color: #ffffff;
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.alerts-tool-button,
.alerts-row-button,
.alerts-footer-button {
min-width: 64px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.alerts-action-button {
width: 100%;
min-width: 0;
}
.alerts-toolbar-grid {
display: grid;
grid-template-columns: minmax(180px, 1.2fr) repeat(4, minmax(110px, .6fr));
gap: 8px;
margin-bottom: 8px;
}
.alerts-input,
.alerts-select,
.alerts-textarea {
width: 100%;
min-width: 0;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.alerts-input,
.alerts-select {
height: 28px;
}
.alerts-table-wrap {
height: 430px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.alerts-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
color: #000000;
}
.alerts-table thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 6px;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 0 1px 0 #ffffff;
}
.alerts-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.alerts-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.alerts-table tbody tr:hover { background: #d8e5f8; }
.alerts-table tbody tr.is-selected { background: #c5dcff; }
.alerts-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.alerts-col-check { width: 34px; }
.alerts-col-severity { width: 76px; }
.alerts-col-status { width: 82px; }
.alerts-col-code { width: 70px; }
.alerts-col-time { width: 110px; }
.alerts-col-actions { width: 88px; }
.alerts-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.alerts-pill.low { background: #deebff; }
.alerts-pill.medium { background: #fff2c8; }
.alerts-pill.high { background: #ffdcdc; }
.alerts-pill.open { background: #f2e1ff; }
.alerts-pill.acked { background: #e2f0e2; }
.alerts-pill.closed { background: #ececec; }
.alerts-info-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.alerts-info-item {
display: grid;
grid-template-columns: 110px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 6px 8px;
background: #f5f5f5;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #c0c0c0;
border-bottom: 1px solid #c0c0c0;
}
.alerts-info-item strong {
font-size: 13px;
line-height: 13px;
}
.alerts-info-item span {
min-width: 0;
color: #222222;
word-break: break-word;
}
.alerts-json-box {
max-height: 180px;
overflow: auto;
margin: 0;
padding: 8px;
color: #b7ffc8;
background: #050505;
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
font-family: "MonoCraft", "Courier New", monospace;
font-size: 12px;
line-height: 15px;
white-space: pre-wrap;
word-break: break-word;
}
.alerts-mini-note {
margin-top: 8px;
padding: 8px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
font-size: 12px;
line-height: 15px;
}
.alerts-action-stack {
display: grid;
gap: 8px;
}
.alerts-footerbar {
flex: 0 0 auto;
min-height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-top: 1px solid #ffffff;
background: #dfdfdf;
}
.alerts-footer-left,
.alerts-footer-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-width: 0;
}
.alerts-status-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-size: 12px;
line-height: 12px;
}
@media (max-width: 1120px) {
.alerts-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.alerts-content-grid {
grid-template-columns: 1fr;
}
.alerts-toolbar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.alerts-summary-grid,
.alerts-toolbar-grid {
grid-template-columns: 1fr;
}
.alerts-table-wrap {
height: 360px;
}
.alerts-panel-header,
.alerts-footerbar {
align-items: flex-start;
flex-direction: column;
}
.alerts-info-item {
grid-template-columns: 1fr;
}
}

501
static/css/boxes.css Normal file
View File

@@ -0,0 +1,501 @@
.boxes-page-body {
display: grid;
gap: 10px;
}
.boxes-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.boxes-stat-card {
min-width: 0;
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
}
.boxes-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.boxes-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.boxes-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.boxes-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.boxes-stat-label {
margin: 0 0 4px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
color: #333333;
}
.boxes-stat-value {
margin: 0;
font-size: 24px;
line-height: 24px;
font-weight: bold;
}
.boxes-stat-note {
margin: 6px 0 0;
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: rgba(255,255,255,.65);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a0a0a0;
border-bottom: 1px solid #a0a0a0;
font-size: 12px;
line-height: 12px;
}
.boxes-hero-note {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
}
.boxes-hero-note strong {
font-size: 13px;
line-height: 13px;
}
.boxes-hero-note span {
font-size: 13px;
line-height: 15px;
}
.boxes-hero-tags {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.boxes-hero-tag,
.boxes-flag {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.boxes-content-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(320px, .75fr);
gap: 10px;
min-height: 0;
}
.boxes-column {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.boxes-panel {
display: flex;
flex-direction: column;
min-height: 0;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
}
.boxes-files-panel {
min-height: 300px;
}
.boxes-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 1px 1px 0 #f7f7f7;
}
.boxes-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 22px;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.boxes-panel-sub {
color: #444444;
font-size: 12px;
line-height: 12px;
font-weight: normal;
}
.boxes-panel-tools {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.boxes-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
overflow: hidden;
background-color: #ffffff;
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.boxes-tool-button,
.boxes-page-button,
.boxes-action-button,
.boxes-row-button {
min-width: 62px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.boxes-tool-button.is-danger,
.boxes-action-button.is-danger {
color: #ffffff;
background: #800000;
}
.boxes-toolbar-grid {
display: grid;
grid-template-columns: minmax(200px, 1.3fr) repeat(4, minmax(110px, .55fr));
gap: 8px;
margin-bottom: 8px;
}
.boxes-input,
.boxes-select {
width: 100%;
min-width: 0;
height: 28px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.boxes-table-wrap {
width: 100%;
min-height: 0;
height: 460px;
overflow-y: auto;
overflow-x: hidden;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.boxes-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
color: #000000;
}
.boxes-table thead th {
position: sticky;
top: 0;
z-index: 2;
padding: 6px;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 0 1px 0 #ffffff;
}
.boxes-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.boxes-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.boxes-table tbody tr:hover { background: #d8e5f8; }
.boxes-table tbody tr.is-selected { background: #c5dcff; }
.boxes-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.boxes-col-check { width: 34px; }
.boxes-col-id { width: 190px; }
.boxes-col-status { width: 84px; }
.boxes-col-files { width: 58px; }
.boxes-col-size { width: 76px; }
.boxes-col-retention { width: 96px; }
.boxes-col-expires { width: 126px; }
.boxes-col-actions { width: 98px; }
.boxes-status-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.boxes-status-pill.ready { background: #def2e0; }
.boxes-status-pill.uploading { background: #fff1c9; }
.boxes-status-pill.attention { background: #ffe2bf; }
.boxes-status-pill.expired { background: #ffdcdc; }
.boxes-status-pill.consumed { background: #ead7ff; }
.boxes-status-pill.legacy { background: #ececec; }
.boxes-flags-cell,
.boxes-action-cell {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
min-width: 0;
overflow: hidden;
}
.boxes-action-cell a {
text-decoration: none;
}
.boxes-empty-state {
padding: 24px 12px;
text-align: center;
color: #444444;
background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(242,242,242,.95));
font-size: 14px;
line-height: 16px;
}
.boxes-footer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
font-size: 12px;
line-height: 12px;
}
.boxes-pagination {
display: flex;
align-items: center;
gap: 8px;
}
.boxes-detail-body {
display: grid;
gap: 10px;
}
.boxes-info-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.boxes-info-item {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 6px 8px;
background: #f5f5f5;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #c0c0c0;
border-bottom: 1px solid #c0c0c0;
}
.boxes-info-item strong {
font-size: 13px;
line-height: 13px;
}
.boxes-info-item span {
min-width: 0;
color: #222222;
word-break: break-word;
}
.boxes-action-stack {
display: grid;
gap: 6px;
}
.boxes-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.boxes-action-button {
width: 100%;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
}
.boxes-file-list {
display: grid;
gap: 8px;
min-height: 0;
height: 320px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
}
.boxes-column:first-child > .boxes-panel {
flex: 1 1 auto;
}
.boxes-column:first-child > .boxes-panel > .boxes-panel-body {
display: flex;
flex-direction: column;
}
.boxes-column:first-child .boxes-table-wrap {
flex: 1 1 auto;
height: auto;
min-height: 560px;
}
.boxes-file-card {
display: grid;
gap: 6px;
padding: 8px;
background: #f8f8f8;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #c0c0c0;
border-bottom: 1px solid #c0c0c0;
}
.boxes-file-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.boxes-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 13px;
font-weight: bold;
}
.boxes-file-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
color: #333333;
font-size: 12px;
line-height: 12px;
}
.boxes-file-link {
text-decoration: none;
}
@media (max-width: 1100px) {
.boxes-summary-grid,
.boxes-content-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.boxes-column-side {
grid-column: 1 / -1;
}
.boxes-toolbar-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.boxes-summary-grid,
.boxes-content-grid,
.boxes-toolbar-grid,
.boxes-action-grid {
grid-template-columns: minmax(0, 1fr);
}
.boxes-hero-note,
.boxes-footer-bar {
align-items: flex-start;
flex-direction: column;
}
.boxes-table-wrap {
height: 420px;
}
.boxes-column:first-child .boxes-table-wrap {
min-height: 420px;
}
}

289
static/css/dashboard.css Normal file
View File

@@ -0,0 +1,289 @@
/* ==============================================
Dashboard-specific styles (shared with admin)
Reusable across account dashboard pages
============================================== */
/* Hero section */
.dashboard-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 330px;
gap: 10px;
padding: 9px;
align-items: stretch;
}
.hero-copy h2 { margin: 0 0 5px; font-size: 22px; line-height: 24px; }
.hero-copy p { margin: 0; color: #333; font-size: 13px; line-height: 15px; }
.hero-status {
display: grid;
gap: 4px;
align-content: center;
padding: 7px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
font-size: 12px;
line-height: 13px;
}
.hero-status-row { display: flex; justify-content: space-between; gap: 8px; }
.status-ok { color: #008000; }
.status-warn { color: #8a6200; }
.status-danger { color: #800000; }
/* Stats grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.stat-card {
position: relative;
min-height: 122px;
padding: 10px 11px 10px 14px;
overflow: hidden;
}
.stat-card::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 7px;
border-left: 7px solid #000078;
pointer-events: none;
}
.stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); }
.stat-card.is-ok::before { border-left-color: #008000; }
.stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); }
.stat-card.is-info::before { border-left-color: #000078; }
.stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); }
.stat-card.is-warning::before { border-left-color: #ffcc00; }
.stat-card.is-danger {
color: #000;
background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px);
}
.stat-card.is-danger::before { border-left-color: #800000; }
.stat-label { margin: 0 0 6px; color: #333; font-size: 13px; line-height: 13px; font-weight: bold; }
.stat-value { margin: 0 0 7px; font-size: 32px; line-height: 32px; font-weight: bold; }
.stat-note { display: flex; gap: 4px; flex-wrap: wrap; margin: 0; color: #222; font-size: 12px; line-height: 14px; }
.stat-note-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 1px 6px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
white-space: nowrap;
}
/* Main two-column grid */
.dashboard-main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.dashboard-span-2 { grid-column: 1 / -1; }
/* Dashboard body */
.dashboard-body {
display: grid;
gap: 12px;
padding: 10px;
}
/* Section windows */
.section-window { min-height: 0; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38); }
.section-body { margin: 0 6px 6px; padding: 8px; min-height: 0; }
/* Scroll panels */
.scroll-panel { overflow: auto; background: #ffffff; border-top: 2px solid #606060; border-left: 2px solid #606060; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; }
.alerts-scroll { height: 326px; }
.boxes-scroll { height: 352px; }
.activity-scroll { height: 326px; }
/* Alerts */
.alert-list { display: grid; min-width: 0; }
.alert-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
min-height: 74px;
padding: 7px;
color: #000;
border-bottom: 1px solid #dfdfdf;
background: #ffffff;
}
.alert-row:nth-child(even) { background: #f5f8ff; }
.alert-row.is-dismissed { display: none; }
.alert-severity {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 60px;
min-height: 20px;
padding: 2px 5px;
text-transform: uppercase;
font-weight: bold;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}
.alert-row[data-severity="low"] .alert-severity { color: #000078; }
.alert-row[data-severity="medium"] .alert-severity { color: #8a6200; background: #ffffcc; }
.alert-row[data-severity="high"] .alert-severity { color: #ffffff; background: #800000; }
.alert-title { margin: 0 0 3px; font-weight: bold; font-size: 14px; line-height: 15px; }
.alert-desc { margin: 0 0 3px; color: #333; font-size: 12px; line-height: 14px; }
.alert-trace { margin: 0; color: #555; font-family: 'MonoCraft', 'Courier New', monospace; font-size: 10px; line-height: 13px; overflow-wrap: anywhere; }
.alert-actions { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
/* Boxes table */
.box-table {
width: 100%;
min-width: 900px;
border-collapse: collapse;
color: #000;
font-size: 12px;
line-height: 14px;
}
.box-table th, .box-table td { padding: 6px 7px; border-bottom: 1px solid #dfdfdf; text-align: left; vertical-align: middle; }
.box-table th { position: sticky; top: 0; z-index: 5; background: #dfdfdf; border-bottom: 1px solid #808080; }
.box-table tr:nth-child(even) td { background: #f5f8ff; }
.box-actions { display: flex; gap: 5px; flex-wrap: nowrap; }
.box-action-button { min-width: 62px; height: 22px; padding: 0 6px; font-size: 12px; line-height: 12px; }
/* Activity */
.activity-list { display: grid; }
.activity-row {
display: grid;
grid-template-columns: 56px minmax(0, 1fr) auto;
gap: 9px;
align-items: center;
min-height: 48px;
padding: 6px 8px;
border-bottom: 1px solid #dfdfdf;
background: #ffffff;
color: #000;
}
.activity-row:nth-child(even) { background: #f5f8ff; }
.activity-time { font-weight: bold; color: #000078; }
.activity-title { margin: 0 0 2px; font-weight: bold; }
.activity-meta { margin: 0; color: #555; font-size: 12px; line-height: 13px; }
/* Modal / Popup */
.modal-backdrop {
position: fixed;
inset: 0;
display: none;
background: rgba(128, 128, 128, .42);
z-index: 70;
}
.modal-backdrop.is-visible { display: block; }
.popup-window {
position: fixed;
left: 50%;
top: 50%;
transform: translate(calc(-50% - 1px), -50%);
width: min(760px, calc(100vw - 24px));
max-height: min(760px, calc(100vh - 24px));
display: none;
z-index: 80;
}
.popup-window.is-visible { display: flex; animation: popup-open 160ms steps(5, end); }
@keyframes popup-open {
from { transform: translate(calc(-50% - 1px), calc(-50% + 10px)) scale(.97); opacity: .45; }
to { transform: translate(calc(-50% - 1px), -50%) scale(1); opacity: 1; }
}
.popup-body { margin: 0 6px 6px; padding: 10px; max-height: calc(100vh - 90px); overflow: auto; color: #000; }
.metadata-pre {
min-height: 240px;
margin: 0;
padding: 10px;
overflow: auto;
color: #b7ffc8;
background: #030403;
background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px);
font-family: 'MonoCraft', 'Courier New', monospace;
font-size: 12px;
line-height: 16px;
white-space: pre-wrap;
}
/* Tiny button (for alerts / boxes) */
.tiny-button {
min-width: 56px;
height: 22px;
display: inline-grid;
place-items: center;
padding: 0 7px;
color: #000;
background: var(--w98-gray);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #000000;
border-bottom: 1px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-size: 12px;
line-height: 12px;
text-decoration: none;
}
.tiny-button:hover { filter: brightness(1.06); }
/* Compact mode */
body.is-compact .dashboard-body { gap: 8px; }
body.is-compact .section-body { padding: 5px; }
body.is-compact .alerts-scroll,
body.is-compact .boxes-scroll { height: 280px; }
body.is-compact .activity-scroll { height: 280px; }
body.is-compact .alert-row { min-height: 62px; }
body.is-compact .activity-row { min-height: 42px; }
/* Responsive: medium */
@media (max-width: 1180px) {
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-hero { grid-template-columns: 1fr; }
.dashboard-main-grid { grid-template-columns: 1fr; }
.dashboard-span-2 { grid-column: auto; }
.alerts-scroll, .boxes-scroll { height: 310px; }
.activity-scroll { height: 310px; }
}
/* Responsive: small (mobile) */
@media (max-width: 760px) {
.dashboard-body { padding: 6px; gap: 8px; }
.stats-grid { grid-template-columns: 1fr; }
.stat-card { min-height: 112px; }
.alert-row { grid-template-columns: 1fr; min-height: 0; }
.alert-actions { justify-content: flex-start; }
.alerts-scroll, .boxes-scroll, .activity-scroll { height: 320px; }
.boxes-scroll { overflow-x: auto; }
.activity-row { grid-template-columns: 48px minmax(0, 1fr); }
.activity-row .tag { grid-column: 2; justify-self: start; }
.popup-window {
left: 0;
top: 0;
transform: none;
width: 100vw;
height: 100dvh;
max-height: none;
border: 0;
box-shadow: none;
}
.popup-window.is-visible { animation: popup-open-mobile 150ms steps(5, end); }
@keyframes popup-open-mobile { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } }
.popup-body { max-height: calc(100dvh - 40px); }
}

516
static/css/settings.css Normal file
View File

@@ -0,0 +1,516 @@
.settings-page-body {
display: grid;
gap: 10px;
}
.settings-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.settings-stat-card {
min-width: 0;
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
}
.settings-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.settings-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.settings-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.settings-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.settings-stat-label {
margin: 0 0 4px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
color: #333333;
}
.settings-stat-value {
margin: 0;
font-size: 24px;
line-height: 24px;
font-weight: bold;
}
.settings-stat-note {
margin: 6px 0 0;
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: rgba(255,255,255,.65);
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a0a0a0;
border-bottom: 1px solid #a0a0a0;
font-size: 12px;
line-height: 12px;
}
.settings-main-grid {
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
gap: 10px;
min-height: 0;
}
.settings-sidebar-panel {
min-width: 0;
}
.settings-sidebar {
position: sticky;
top: 48px;
}
.settings-workbench {
display: grid;
gap: 10px;
min-width: 0;
}
.settings-panel,
.settings-hero-panel {
min-width: 0;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08);
}
.settings-panel {
display: flex;
flex-direction: column;
}
.settings-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
box-shadow: inset 1px 1px 0 #f7f7f7;
}
.settings-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: 22px;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.settings-panel-sub {
color: #444444;
font-size: 12px;
line-height: 12px;
font-weight: normal;
}
.settings-panel-tools {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.settings-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
overflow: hidden;
background-color: #ffffff;
background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.settings-hero-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr);
gap: 10px;
padding: 10px;
background-image: linear-gradient(180deg, rgba(255,255,255,.92), rgba(238,238,238,.58));
}
.settings-hero-copy h2 {
margin: 0 0 6px;
font-size: 18px;
line-height: 18px;
}
.settings-hero-copy p {
margin: 0;
color: #222222;
font-size: 13px;
line-height: 16px;
}
.settings-hero-legend {
display: grid;
gap: 6px;
}
.settings-legend-row {
display: flex;
align-items: center;
gap: 6px;
color: #222222;
font-size: 12px;
line-height: 12px;
}
.settings-search {
display: grid;
gap: 6px;
margin-bottom: 8px;
}
.settings-search label {
font-weight: bold;
font-size: 13px;
line-height: 13px;
}
.settings-input,
.settings-select {
width: 100%;
min-width: 0;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.settings-input,
.settings-select {
height: 28px;
}
.settings-category-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.settings-category-button {
width: 100%;
min-height: 30px;
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
padding: 4px 6px;
color: #000000;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
font-family: inherit;
text-align: left;
}
.settings-category-button.is-active {
color: #ffffff;
background: #000078;
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
}
.settings-category-count,
.settings-dirty-chip,
.settings-badge {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.settings-category-button.is-active .settings-category-count {
color: #000000;
background: #ffffcc;
}
.settings-dirty-chip {
min-width: 78px;
justify-content: center;
}
.settings-dirty-chip.is-dirty {
background: #ffffcc;
border: 3px solid transparent;
border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3;
}
.badge-default { background: #ececec; }
.badge-env { background: #c7d8f2; }
.badge-db { background: #d2efcf; }
.badge-hard { background: #ffd9d9; }
.settings-tool-button,
.settings-mini-button,
.settings-popup-close {
min-width: 64px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.settings-action-summary {
margin-bottom: 8px;
padding: 8px;
color: #000000;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
font-size: 12px;
line-height: 15px;
}
.settings-groups {
display: grid;
gap: 10px;
min-height: 0;
max-height: 700px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
}
.settings-group {
display: grid;
gap: 0;
}
.settings-group[hidden] {
display: none;
}
.settings-group-title {
min-height: 28px;
padding: 6px 8px;
color: #000000;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
font-weight: bold;
font-size: 14px;
line-height: 14px;
}
.settings-table-wrap {
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
background: #ffffff;
}
.settings-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
color: #000000;
font-size: 12px;
line-height: 14px;
}
.settings-table th,
.settings-table td {
padding: 6px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid #e1e1e1;
}
.settings-table th {
position: sticky;
top: 0;
z-index: 2;
background: #dfdfdf;
box-shadow: inset 0 1px 0 #ffffff;
}
.settings-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.settings-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.setting-row.is-locked { color: #555555; background: #efefef; }
.setting-row.is-hidden { display: none; }
.setting-row.is-invalid { background: #fff1c9; }
.setting-meta {
display: grid;
gap: 4px;
}
.setting-meta strong {
font-size: 13px;
line-height: 13px;
}
.setting-meta code {
color: #1b325f;
font-size: 11px;
line-height: 12px;
word-break: break-word;
}
.setting-control {
display: grid;
gap: 4px;
}
.setting-input-row {
display: flex;
align-items: center;
gap: 6px;
}
.setting-hint {
color: #444444;
font-size: 11px;
line-height: 13px;
}
.setting-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.settings-modal-backdrop {
position: fixed;
inset: 0;
display: none;
background: rgba(0,0,0,.35);
z-index: 90;
}
.settings-modal-backdrop.is-visible {
display: block;
}
.settings-popup {
position: fixed;
left: 50%;
top: 50%;
width: min(520px, calc(100vw - 24px));
display: none;
transform: translate(-50%, -50%);
color: #000000;
background: var(--w98-gray);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: 6px 6px 0 rgba(0,0,0,.35);
z-index: 95;
}
.settings-popup.is-visible {
display: block;
}
.settings-popup-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
padding: 4px 6px;
color: #ffffff;
background: #000078;
}
.settings-popup-body {
padding: 10px;
background: #f5f5f5;
font-size: 13px;
line-height: 16px;
}
.settings-popup-body p,
.settings-popup-body ul {
margin: 0 0 8px;
}
@media (max-width: 1100px) {
.settings-summary-grid,
.settings-main-grid,
.settings-hero-panel {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-sidebar-panel,
.settings-workbench {
grid-column: 1 / -1;
}
.settings-sidebar {
position: static;
}
}
@media (max-width: 720px) {
.settings-summary-grid,
.settings-main-grid,
.settings-hero-panel {
grid-template-columns: minmax(0, 1fr);
}
.settings-panel-header {
align-items: flex-start;
flex-direction: column;
}
.settings-category-list {
grid-template-columns: minmax(0, 1fr);
}
.settings-table-wrap {
overflow-x: auto;
}
.settings-table {
min-width: 760px;
}
}

309
static/css/users.css Normal file
View File

@@ -0,0 +1,309 @@
.users-page-body {
display: grid;
gap: 10px;
}
.users-hero {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr);
gap: 10px;
padding: 10px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-hero h2 {
margin: 0 0 6px;
font-size: 24px;
line-height: 24px;
}
.users-hero p {
margin: 0;
color: #333333;
font-size: 13px;
line-height: 16px;
}
.users-hero-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-content: start;
}
.users-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.users-stat-card {
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}
.users-stat-card p {
margin: 0 0 6px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.users-stat-card strong {
font-size: 24px;
line-height: 24px;
}
.users-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.users-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.users-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.users-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.users-main-grid {
display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
gap: 10px;
min-height: 0;
}
.users-panel {
min-height: 0;
display: flex;
flex-direction: column;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-panel-header {
flex: 0 0 auto;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.users-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.users-panel-title span {
font-weight: normal;
color: #444444;
font-size: 12px;
line-height: 12px;
}
.users-panel-tools {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.users-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.users-list-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 8px;
overflow: hidden;
}
.users-form-grid {
display: grid;
gap: 8px;
}
.users-row-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.users-field {
display: grid;
gap: 4px;
font-size: 12px;
line-height: 12px;
}
.users-input,
.users-select {
width: 100%;
min-width: 0;
height: 28px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.users-check {
min-height: 20px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.users-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.users-action-button,
.users-tool-button,
.users-page-button {
min-width: 70px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.users-toolbar-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
gap: 8px;
}
.users-table-wrap {
min-height: 420px;
height: 420px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.users-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
}
.users-table th,
.users-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.users-table th {
position: sticky;
top: 0;
z-index: 2;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.users-table tbody tr:hover { background: #d8e5f8; }
.users-col-check { width: 30px; }
.users-col-actions { width: 136px; }
.users-username {
display: grid;
gap: 2px;
}
.users-username strong {
font-size: 13px;
line-height: 13px;
}
.users-muted {
color: #555555;
font-size: 11px;
line-height: 11px;
}
.users-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.users-pill.active { background: #def2e0; }
.users-pill.pending { background: #fff1c9; }
.users-pill.disabled { background: #ffdcdc; }
.users-row-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
}
.users-row-button {
min-width: 60px;
height: 22px;
padding: 0 6px;
font-size: 12px;
line-height: 12px;
}
.users-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
line-height: 12px;
}
@media (max-width: 1024px) {
.users-main-grid,
.users-hero {
grid-template-columns: 1fr;
}
}

View File

@@ -128,3 +128,81 @@
font-size: 13px;
line-height: 13px;
}
/* Raised panel - appears to sit above the surface */
.raised-panel {
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
}
/* Sunken panel - appears to be inset into the surface */
.sunken-panel {
background-color: #ffffff;
background-image:
linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)),
repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px);
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
/* Scroll panel - used for scrollable content areas within windows */
.scroll-panel {
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
/* Meter track for progress bars */
.meter-track {
display: block;
height: 14px;
margin-top: 9px;
background-color: #ffffff;
background-image: repeating-linear-gradient(to right, rgba(0,0,0,.06) 0 1px, transparent 1px 18px);
border-top: 2px solid #808080;
border-left: 2px solid #808080;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.meter-bar {
display: block;
height: 100%;
width: var(--meter, 0%);
background-color: #000078;
background-image: repeating-linear-gradient(to right, rgba(255,255,255,.13) 0 1px, transparent 1px 18px);
}
/* Tag styles for status indicators */
.tag {
display: inline-flex;
align-items: center;
min-height: 17px;
margin: 1px 2px 1px 0;
padding: 1px 5px;
color: #000000;
background: #dfdfdf;
border: 1px solid #808080;
box-shadow: inset 1px 1px 0 #ffffff;
white-space: nowrap;
}
.tag.ok { color: #008000; background: #eeffee; }
.tag.info { color: #000078; background: #edf4ff; }
.tag.warn { color: #8a6200; background: #ffffcc; }
.tag.danger { color: #ffffff; background: #800000; }
/* Titlebar animation - gradient drift */
@keyframes titlebar-drift {
from { background-position: 0% 50%; }
to { background-position: 100% 50%; }
}

216
static/js/admin/alerts.js Normal file
View File

@@ -0,0 +1,216 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toast = document.getElementById("toast");
const searchInput = document.getElementById("search-input");
const severityFilter = document.getElementById("severity-filter");
const statusFilter = document.getElementById("status-filter");
const sourceFilter = document.getElementById("source-filter");
const sortFilter = document.getElementById("sort-filter");
const alertsBody = document.getElementById("alerts-body");
const selectedCountEl = document.getElementById("selected-count");
const openCountEl = document.querySelector("[data-open-count]");
const highCountEl = document.querySelector("[data-high-count]");
const ackCountEl = document.querySelector("[data-ack-count]");
const closedCountEl = document.querySelector("[data-closed-count]");
const selectAll = document.getElementById("select-all");
const detailEls = {
title: document.getElementById("detail-title"),
severity: document.getElementById("detail-severity"),
status: document.getElementById("detail-status"),
code: document.getElementById("detail-code"),
trace: document.getElementById("detail-trace"),
time: document.getElementById("detail-time"),
description: document.getElementById("detail-description"),
metadata: document.getElementById("detail-metadata")
};
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
function showToast(message, type = "info", duration = 1800) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast, duration });
return;
}
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
}
function allRows() {
return Array.from(alertsBody.querySelectorAll("tr"));
}
function visibleRows() {
return allRows().filter((row) => row.style.display !== "none");
}
function selectedRows() {
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
}
function updateSelectedCount() {
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
}
function updateSummaryCounts() {
const rows = visibleRows();
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
}
function updateDetails(row) {
if (!row) return;
allRows().forEach((item) => item.classList.remove("is-selected"));
row.classList.add("is-selected");
detailEls.title.textContent = row.dataset.title || "";
detailEls.severity.textContent = row.dataset.severity || "";
detailEls.status.textContent = row.dataset.status || "";
detailEls.code.textContent = row.dataset.code || "";
detailEls.trace.textContent = row.dataset.trace || "";
detailEls.time.textContent = row.dataset.time || "";
detailEls.description.textContent = row.dataset.description || "";
try {
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
} catch (_) {
detailEls.metadata.textContent = row.dataset.metadata || "{}";
}
}
function applyFilters() {
const search = searchInput.value.trim().toLowerCase();
const severity = severityFilter.value;
const status = statusFilter.value;
const group = sourceFilter.value;
allRows().forEach((row) => {
const haystack = [
row.dataset.title,
row.dataset.description,
row.dataset.code,
row.dataset.trace,
row.dataset.group
].join(" ").toLowerCase();
const matchesSearch = !search || haystack.includes(search);
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
const matchesStatus = status === "all" || row.dataset.status === status;
const matchesGroup = group === "all" || row.dataset.group === group;
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
});
const order = { high: 3, medium: 2, low: 1 };
visibleRows().sort((a, b) => {
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
return Number(b.dataset.id) - Number(a.dataset.id);
}).forEach((row) => alertsBody.appendChild(row));
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
updateSelectedCount();
updateSummaryCounts();
}
function setRowStatus(row, nextStatus) {
row.dataset.status = nextStatus;
const statusCell = row.children[3]?.querySelector(".alerts-pill");
if (!statusCell) return;
statusCell.className = `alerts-pill ${nextStatus}`;
statusCell.textContent = nextStatus;
}
function changeSelectedStatus(nextStatus) {
const rows = selectedRows();
if (!rows.length) {
showToast("Select one or more alerts first", "warning");
return;
}
rows.forEach((row) => {
setRowStatus(row, nextStatus);
row.querySelector(".row-check").checked = false;
});
if (selectAll) selectAll.checked = false;
updateSelectedCount();
updateSummaryCounts();
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
if (currentRow) updateDetails(currentRow);
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
}
const commandMessages = {
refresh: "Alerts refreshed in mock view",
export: "Visible alerts exported in mock view",
"copy-meta": "Metadata copied in mock view",
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
"help-meta": "Metadata explains why the alert happened and includes extra context."
};
function runCommand(command) {
switch (command) {
case "ack":
changeSelectedStatus("acked");
return;
case "close":
changeSelectedStatus("closed");
return;
case "open-only":
statusFilter.value = "open";
applyFilters();
showToast("Showing open alerts only");
return;
default:
showToast(commandMessages[command] || `Mock action: ${command}`);
}
}
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
});
allRows().forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("input")) return;
updateDetails(row);
});
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
});
selectAll?.addEventListener("change", () => {
visibleRows().forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (checkbox) checkbox.checked = selectAll.checked;
});
updateSelectedCount();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
}
});
applyFilters();
updateDetails(allRows()[0]);
})();

526
static/js/admin/boxes.js Normal file
View File

@@ -0,0 +1,526 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toastTarget = document.getElementById("toast");
const dataNode = document.getElementById("boxes-data");
const tableBody = document.getElementById("boxes-table-body");
const emptyState = document.getElementById("boxes-empty-state");
const searchInput = document.getElementById("boxes-search");
const statusFilter = document.getElementById("boxes-status-filter");
const flagFilter = document.getElementById("boxes-flag-filter");
const sortFilter = document.getElementById("boxes-sort");
const pageSizeFilter = document.getElementById("boxes-page-size");
const selectAll = document.getElementById("boxes-select-all");
const prevPageButton = document.getElementById("boxes-prev-page");
const nextPageButton = document.getElementById("boxes-next-page");
const pageLabel = document.getElementById("boxes-page-label");
const rangeLabel = document.getElementById("boxes-range-label");
const selectedLabel = document.getElementById("boxes-selected-label");
const footerSummary = document.getElementById("boxes-footer-summary");
const detailFileList = document.getElementById("detail-file-list");
if (!dataNode || !tableBody || !searchInput || !detailFileList) return;
const statEls = {
total: document.querySelector("[data-stat-total]"),
ready: document.querySelector("[data-stat-ready]"),
uploading: document.querySelector("[data-stat-uploading]"),
expired: document.querySelector("[data-stat-expired]")
};
const detailEls = {
boxId: document.getElementById("detail-box-id"),
status: document.getElementById("detail-status"),
created: document.getElementById("detail-created"),
expires: document.getElementById("detail-expires"),
retention: document.getElementById("detail-retention"),
files: document.getElementById("detail-files"),
size: document.getElementById("detail-size"),
flags: document.getElementById("detail-flags"),
open: document.getElementById("detail-open"),
zip: document.getElementById("detail-zip")
};
function showToast(message, type = "info", duration = 2200) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration });
return;
}
if (!toastTarget) return;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
window.setTimeout(() => toastTarget.classList.remove("is-visible"), duration);
}
function parseData() {
try {
return JSON.parse(dataNode.textContent || "[]");
} catch (_) {
return [];
}
}
const state = {
boxes: parseData(),
selected: new Set(),
activeId: null,
page: 1
};
function pageSize() {
return Number(pageSizeFilter.value || 10);
}
function allBoxes() {
return state.boxes.slice();
}
function sortBoxes(boxes) {
const sorted = boxes.slice();
switch (sortFilter.value) {
case "name":
sorted.sort((a, b) => a.id.localeCompare(b.id));
break;
case "largest":
sorted.sort((a, b) => compareSizeLabel(a.total_size_label, b.total_size_label));
break;
case "expires":
sorted.sort((a, b) => compareExpiry(a.expires_at_iso, b.expires_at_iso));
break;
default:
sorted.sort((a, b) => (b.created_at_iso || "").localeCompare(a.created_at_iso || ""));
}
return sorted;
}
function compareSizeLabel(left, right) {
return sizeLabelToBytes(right) - sizeLabelToBytes(left);
}
function sizeLabelToBytes(label) {
const match = String(label || "").trim().match(/^([\d.]+)\s*([KMGT]?i?B|B)$/i);
if (!match) return 0;
const value = Number(match[1]);
const unit = match[2].toUpperCase();
const map = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 };
return value * (map[unit] || 1);
}
function compareExpiry(left, right) {
if (!left && !right) return 0;
if (!left) return 1;
if (!right) return -1;
return left.localeCompare(right);
}
function filteredBoxes() {
const query = searchInput.value.trim().toLowerCase();
const status = statusFilter.value;
const flag = flagFilter.value;
return sortBoxes(allBoxes().filter((box) => {
const matchesSearch = !query || String(box.search_text || "").includes(query);
const matchesStatus = status === "all" || box.status === status;
const matchesFlag = flag === "all" || (box.flags || []).includes(flag);
return matchesSearch && matchesStatus && matchesFlag;
}));
}
function pagedBoxes(boxes) {
const size = pageSize();
const pages = Math.max(1, Math.ceil(boxes.length / size));
if (state.page > pages) state.page = pages;
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * size;
return {
items: boxes.slice(start, start + size),
start,
pages
};
}
function selectedBoxes() {
return allBoxes().filter((box) => state.selected.has(box.id));
}
function currentActiveBox() {
const boxes = allBoxes();
return boxes.find((box) => box.id === state.activeId) || null;
}
function ensureActiveBox(filtered) {
if (filtered.length === 0) {
state.activeId = null;
return null;
}
if (!filtered.some((box) => box.id === state.activeId)) {
state.activeId = filtered[0].id;
}
return filtered.find((box) => box.id === state.activeId) || filtered[0];
}
function renderSummary(filtered) {
const total = filtered.length;
const ready = filtered.filter((box) => box.status === "ready").length;
const uploading = filtered.filter((box) => box.status === "uploading").length;
const expired = filtered.filter((box) => box.status === "expired" || box.status === "consumed").length;
statEls.total.textContent = String(total);
statEls.ready.textContent = String(ready);
statEls.uploading.textContent = String(uploading);
statEls.expired.textContent = String(expired);
footerSummary.textContent = `${allBoxes().length} boxes loaded`;
selectedLabel.textContent = `Selected: ${state.selected.size}`;
}
function renderTable() {
const filtered = filteredBoxes();
const active = ensureActiveBox(filtered);
const page = pagedBoxes(filtered);
tableBody.innerHTML = "";
page.items.forEach((box) => tableBody.appendChild(buildRow(box)));
emptyState.hidden = page.items.length !== 0;
const startIndex = filtered.length ? page.start + 1 : 0;
const endIndex = page.start + page.items.length;
rangeLabel.textContent = `Showing ${startIndex}-${endIndex} of ${filtered.length}`;
pageLabel.textContent = `Page ${state.page} / ${page.pages}`;
prevPageButton.disabled = state.page <= 1;
nextPageButton.disabled = state.page >= page.pages;
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
renderSummary(filtered);
renderDetails(active);
}
function buildRow(box) {
const row = document.createElement("tr");
if (box.id === state.activeId) row.classList.add("is-selected");
row.innerHTML = `
<td><input type="checkbox" class="boxes-row-check"${state.selected.has(box.id) ? " checked" : ""}></td>
<td title="${escapeAttr(box.id)}">${box.id}</td>
<td><span class="boxes-status-pill ${box.status}">${box.status_label}</span></td>
<td>${box.complete_files}/${box.file_count}</td>
<td>${box.total_size_label}</td>
<td>${box.retention_label || "Not set"}</td>
<td>${box.expires_at_label || "Not set"}</td>
<td><div class="boxes-flags-cell">${renderFlags(box.flags)}</div></td>
<td><div class="boxes-action-cell">${renderRowActions(box)}</div></td>
`;
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("a") || event.target.closest("input")) return;
state.activeId = box.id;
renderTable();
});
row.querySelector(".boxes-row-check")?.addEventListener("change", (event) => {
if (event.target.checked) {
state.selected.add(box.id);
} else {
state.selected.delete(box.id);
}
selectedLabel.textContent = `Selected: ${state.selected.size}`;
syncSelectAllForPage();
});
row.querySelector('[data-row-action="focus"]')?.addEventListener("click", () => {
state.activeId = box.id;
renderTable();
});
return row;
}
function renderFlags(flags) {
if (!flags || !flags.length) return '<span class="boxes-flag">none</span>';
return flags.map((flag) => `<span class="boxes-flag">${escapeHtml(flag)}</span>`).join("");
}
function renderRowActions(box) {
const parts = [
`<a class="win98-button boxes-row-button" href="${escapeAttr(box.open_url)}" target="_blank" rel="noreferrer">Open</a>`,
`<button class="win98-button boxes-row-button" type="button" data-row-action="focus">View</button>`
];
if (box.zip_available && box.zip_url) {
parts.push(`<a class="win98-button boxes-row-button" href="${escapeAttr(box.zip_url)}" target="_blank" rel="noreferrer">ZIP</a>`);
}
return parts.join("");
}
function renderDetails(box) {
if (!box) {
detailEls.boxId.textContent = "-";
detailEls.status.textContent = "-";
detailEls.created.textContent = "-";
detailEls.expires.textContent = "-";
detailEls.retention.textContent = "-";
detailEls.files.textContent = "-";
detailEls.size.textContent = "-";
detailEls.flags.textContent = "-";
detailEls.open.href = "#";
detailEls.zip.href = "#";
detailEls.zip.setAttribute("aria-disabled", "true");
detailFileList.innerHTML = '<div class="boxes-file-card">No box selected.</div>';
return;
}
detailEls.boxId.textContent = box.id;
detailEls.status.textContent = box.status_label;
detailEls.created.textContent = box.created_at_label || "Not set";
detailEls.expires.textContent = box.expires_at_label || "Not set";
detailEls.retention.textContent = box.retention_label || "Not set";
detailEls.files.textContent = `${box.complete_files}/${box.file_count} complete`;
detailEls.size.textContent = box.total_size_label;
detailEls.flags.textContent = (box.flags || []).join(", ") || "none";
detailEls.open.href = box.open_url || "#";
if (box.zip_available && box.zip_url) {
detailEls.zip.href = box.zip_url;
detailEls.zip.removeAttribute("aria-disabled");
detailEls.zip.style.pointerEvents = "";
detailEls.zip.style.opacity = "";
} else {
detailEls.zip.href = "#";
detailEls.zip.setAttribute("aria-disabled", "true");
detailEls.zip.style.pointerEvents = "none";
detailEls.zip.style.opacity = ".55";
}
renderFiles(box.files || []);
}
function renderFiles(files) {
if (!files.length) {
detailFileList.innerHTML = '<div class="boxes-file-card">No file inventory available for this box.</div>';
return;
}
detailFileList.innerHTML = files.map((file) => `
<div class="boxes-file-card">
<div class="boxes-file-row">
<div class="boxes-file-name" title="${escapeAttr(file.name)}">${escapeHtml(file.name)}</div>
<span class="boxes-status-pill ${escapeAttr(file.status || "legacy")}">${escapeHtml(file.status_label || file.status || "Unknown")}</span>
</div>
<div class="boxes-file-meta">
<span>${escapeHtml(file.size_label || "0 B")}</span>
<span>${escapeHtml(file.mime_type || "application/octet-stream")}</span>
${file.is_complete && file.download_path ? `<a class="boxes-file-link" href="${escapeAttr(file.download_path)}" target="_blank" rel="noreferrer">download</a>` : "<span>pending</span>"}
</div>
</div>
`).join("");
}
function syncSelectAllForPage() {
const filtered = filteredBoxes();
const page = pagedBoxes(filtered);
selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id));
selectedLabel.textContent = `Selected: ${state.selected.size}`;
}
function clearFilters() {
searchInput.value = "";
statusFilter.value = "all";
flagFilter.value = "all";
sortFilter.value = "newest";
pageSizeFilter.value = "10";
state.page = 1;
renderTable();
}
async function runBulkAction(action, ids, deltaSeconds = 0) {
if (!ids.length) {
showToast("Select one or more boxes first", "warning");
return;
}
try {
const response = await fetch("/admin/boxes/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, box_ids: ids, delta_seconds: deltaSeconds })
});
const payload = await response.json();
if (!response.ok) {
const message = payload.error || payload.message || "Action failed";
const warning = Array.isArray(payload.warnings) && payload.warnings.length ? ` (${payload.warnings[0]})` : "";
showToast(`${message}${warning}`, "error", 3200);
return;
}
state.boxes = Array.isArray(payload.boxes) ? payload.boxes : state.boxes;
state.selected.clear();
if (state.activeId && !state.boxes.some((box) => box.id === state.activeId)) {
state.activeId = null;
}
renderTable();
let message = payload.message || "Action complete";
if (Array.isArray(payload.warnings) && payload.warnings.length) {
message += ` (${payload.warnings.length} warning${payload.warnings.length === 1 ? "" : "s"})`;
}
showToast(message, Array.isArray(payload.warnings) && payload.warnings.length ? "warning" : "success", 2800);
} catch (_) {
showToast("Network error while updating boxes", "error", 3200);
}
}
function selectedIDsOrActive() {
if (state.selected.size) return Array.from(state.selected);
const active = currentActiveBox();
return active ? [active.id] : [];
}
async function runCommand(command) {
switch (command) {
case "refresh":
window.location.reload();
return;
case "export":
exportVisibleCSV();
showToast("Visible boxes exported");
return;
case "status-ready":
statusFilter.value = "ready";
state.page = 1;
renderTable();
return;
case "status-expired":
statusFilter.value = "expired";
state.page = 1;
renderTable();
return;
case "clear-filters":
clearFilters();
showToast("Filters cleared");
return;
case "expire":
case "active-expire":
await runBulkAction("expire", selectedIDsOrActive());
return;
case "extend-day":
case "active-extend-day":
await runBulkAction("bump", selectedIDsOrActive(), 24 * 60 * 60);
return;
case "extend-week":
case "active-extend-week":
await runBulkAction("bump", selectedIDsOrActive(), 7 * 24 * 60 * 60);
return;
case "delete":
case "active-delete":
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
await runBulkAction("delete", selectedIDsOrActive());
return;
case "help-scope":
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
return;
case "help-flags":
showToast("Flags: protected, one-time, zip off, legacy, consumed", "info", 3200);
return;
default:
showToast(`Unknown command: ${command}`, "warning");
}
}
function exportVisibleCSV() {
const rows = filteredBoxes().map((box) => ([
box.id,
box.status_label,
box.file_count,
box.total_size_label,
box.retention_label,
box.expires_at_label,
(box.flags || []).join("|")
]));
const csv = [
["box_id", "status", "files", "size", "retention", "expires", "flags"],
...rows
].map((row) => row.map(csvCell).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = "warpbox-boxes.csv";
anchor.click();
URL.revokeObjectURL(url);
}
function csvCell(value) {
const text = String(value ?? "");
if (/[",\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
return text;
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll("'", "&#39;");
}
[searchInput, statusFilter, flagFilter, sortFilter].forEach((control) => {
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1;
renderTable();
});
});
pageSizeFilter.addEventListener("change", () => {
state.page = 1;
renderTable();
});
selectAll?.addEventListener("change", () => {
const filtered = filteredBoxes();
const page = pagedBoxes(filtered);
page.items.forEach((box) => {
if (selectAll.checked) state.selected.add(box.id);
else state.selected.delete(box.id);
});
renderTable();
});
prevPageButton?.addEventListener("click", () => {
state.page -= 1;
renderTable();
});
nextPageButton?.addEventListener("click", () => {
state.page += 1;
renderTable();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
await runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
await runCommand("refresh");
}
});
if (state.boxes.length > 0) {
state.activeId = state.boxes[0].id;
}
renderTable();
})();

View File

@@ -0,0 +1,201 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
close() {
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
};
const toast = document.getElementById("toast");
const statusText = document.getElementById("statusText");
const modal = document.querySelector("[data-alert-modal]");
const backdrop = document.querySelector("[data-modal-backdrop]");
const modalTitle = document.getElementById("modalTitle");
const modalMeta = document.getElementById("modalMeta");
const alertCountValue = document.getElementById("alertCountValue");
const alertStatNote = document.getElementById("alertStatNote");
const alertsCard = document.getElementById("alertsCard");
const topAlertChip = document.getElementById("topAlertChip");
const topTaskbar = document.querySelector(".admin-taskbar");
if (!statusText || !alertsCard || !topAlertChip) return;
function showToast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast });
return;
}
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), 2600);
}
function setStatus(message) {
statusText.textContent = message;
}
function openModal(title, meta) {
if (!modal || !backdrop || !modalTitle || !modalMeta) return;
modalTitle.textContent = title;
modalMeta.textContent = meta;
modal.classList.add("is-visible");
modal.setAttribute("aria-hidden", "false");
backdrop.classList.add("is-visible");
}
function closeModal() {
modal?.classList.remove("is-visible");
modal?.setAttribute("aria-hidden", "true");
backdrop?.classList.remove("is-visible");
}
function visibleAlertRows() {
return Array.from(document.querySelectorAll(".alert-row")).filter((row) => !row.classList.contains("is-dismissed"));
}
function updateStickyHeader() {
topTaskbar?.classList.toggle("is-scrolled", window.scrollY > 4);
}
function updateAlertSummary() {
const rows = visibleAlertRows();
const counts = rows.reduce((acc, row) => {
const severity = row.dataset.severity || "low";
acc[severity] = (acc[severity] || 0) + 1;
return acc;
}, { high: 0, medium: 0, low: 0 });
const score = counts.high * 5 + counts.medium * 2 + counts.low;
const total = rows.length;
const stateClass = counts.high > 0 || score >= 12 ? "is-danger" : counts.medium >= 2 || score >= 5 ? "is-warning" : total > 0 ? "is-info" : "is-ok";
alertsCard.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
alertsCard.classList.add(stateClass);
topAlertChip.classList.remove("is-ok", "is-info", "is-warning", "is-danger");
topAlertChip.classList.add(stateClass);
if (alertCountValue) alertCountValue.textContent = String(total);
topAlertChip.textContent = total === 0 ? "OK no alerts" : `! ${total} alerts`;
if (alertStatNote) {
alertStatNote.innerHTML = total === 0
? '<span class="stat-note-pill">all clear</span>'
: `<span class="stat-note-pill">${counts.high} high</span><span class="stat-note-pill">${counts.medium} medium</span><span class="stat-note-pill">${counts.low} low</span>`;
}
}
function scrollToSection(id) {
const target = document.getElementById(id);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" });
setStatus(`Focused ${id.replace("-", " ")}`);
}
const commandMessages = {
refresh: "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard refresh would re-fetch dashboard data.",
"dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.",
logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.",
"compact-mode": "Toggled compact density.",
"show-all-boxes": "TO-DO: navigate to the admin boxes view when that page exists.",
"show-all-alerts": "TO-DO: navigate to /admin/alerts.",
"export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.",
"export-alerts": "CURRENTLY_MOCKED_LEAVE_AS_IS: alerts JSON export would be requested.",
"cleanup-dry-run": "CURRENTLY_MOCKED_LEAVE_AS_IS: cleanup dry run would calculate affected boxes without deleting.",
"dismiss-low-alerts": "Closed visible low-severity alerts in this mock.",
"config-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: config snapshot would summarize runtime settings and sources.",
"support-summary": "CURRENTLY_MOCKED_LEAVE_AS_IS: support summary would collect safe diagnostic information.",
"thumbnail-rebuild": "CURRENTLY_MOCKED_LEAVE_AS_IS: thumbnail rebuild would enqueue preview regeneration.",
"open-users": "TO-DO: navigate to the admin users view when that page exists.",
"open-settings": "TO-DO: navigate to the admin settings view when that page exists.",
"alerts-help": "Alerts use title, description, severity, metadata JSON, trace identifier, and unique numeric code.",
shortcuts: "Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.",
about: "WarpBox dashboard mock v5, single-window Win98 account dashboard."
};
function runCommand(command) {
if (command === "compact-mode") document.body.classList.toggle("is-compact");
if (command === "dismiss-low-alerts") {
document.querySelectorAll('.alert-row[data-severity="low"]').forEach((row) => row.classList.add("is-dismissed"));
updateAlertSummary();
}
if (command === "show-all-boxes") window.location.hash = "recent-boxes";
if (command === "show-all-alerts") window.location.hash = "alerts";
const message = commandMessages[command] || `Command: ${command}`;
showToast(message);
setStatus(message);
}
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.querySelectorAll("[data-scroll-to]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
scrollToSection(button.dataset.scrollTo);
});
});
document.querySelectorAll("[data-view-meta]").forEach((button) => {
button.addEventListener("click", () => {
const row = button.closest(".alert-row");
const title = row?.dataset.alertTitle || "Alert Metadata";
let meta = row?.dataset.alertMeta || "{}";
try {
meta = JSON.stringify(JSON.parse(meta), null, 2);
} catch (_) {
meta = row?.dataset.alertMeta || "{}";
}
openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta);
});
});
document.querySelectorAll("[data-dismiss-alert]").forEach((button) => {
button.addEventListener("click", () => {
const row = button.closest(".alert-row");
row?.classList.add("is-dismissed");
updateAlertSummary();
showToast(`Closed alert ${row?.dataset.alertCode || "mock"}.`);
setStatus(`Closed alert ${row?.dataset.alertCode || "mock"}`);
});
});
document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal);
backdrop?.addEventListener("click", closeModal);
topAlertChip.addEventListener("click", (event) => {
event.preventDefault();
scrollToSection("alerts");
});
window.addEventListener("scroll", updateStickyHeader, { passive: true });
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
menuController.close();
closeModal();
}
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
}
if (event.altKey && event.key.toLowerCase() === "a") {
event.preventDefault();
scrollToSection("alerts");
}
if (event.altKey && event.key.toLowerCase() === "b") {
event.preventDefault();
scrollToSection("recent-boxes");
}
if (event.altKey && event.key.toLowerCase() === "r") {
event.preventDefault();
scrollToSection("recent-activity");
}
});
updateAlertSummary();
updateStickyHeader();
})();

459
static/js/admin/settings.js Normal file
View File

@@ -0,0 +1,459 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const rowsNode = document.getElementById("settings-rows");
const searchInput = document.getElementById("settingsSearch");
const categoryButtons = Array.from(document.querySelectorAll(".settings-category-button"));
const groups = Array.from(document.querySelectorAll(".settings-group"));
const saveButton = document.getElementById("saveButton");
const exportButton = document.getElementById("exportButton");
const importButton = document.getElementById("importButton");
const resetButton = document.getElementById("resetButton");
const importInput = document.getElementById("settingsImportInput");
const dirtyChip = document.getElementById("dirtyChip");
const actionSummary = document.getElementById("actionSummary");
const visibleCount = document.getElementById("visibleCount");
const editableCount = document.getElementById("editableCount");
const unsavedCount = document.getElementById("unsavedCount");
const lockedCount = document.getElementById("lockedCount");
const statusLeft = document.getElementById("statusLeft");
const statusMiddle = document.getElementById("statusMiddle");
const statusRight = document.getElementById("statusRight");
const popupClose = document.getElementById("doc-popup-close");
const toastTarget = document.getElementById("toast");
if (!rowsNode || !searchInput || !saveButton) return;
const state = {
currentCategory: "all",
showChangedOnly: false,
showLockedOnly: false
};
function parseRows() {
try {
return JSON.parse(rowsNode.textContent || "[]");
} catch (_) {
return [];
}
}
const rowData = parseRows().reduce((map, row) => {
map[row.key] = row;
return map;
}, {});
const rows = Array.from(document.querySelectorAll(".setting-row")).map((row) => ({
element: row,
input: row.querySelector(".setting-input"),
hint: row.querySelector('[data-role="hint"]'),
badge: row.querySelector('[data-role="source-badge"]'),
key: row.dataset.key,
label: row.dataset.label,
category: row.dataset.category,
envName: row.dataset.envName,
type: row.dataset.type,
minimum: Number(row.dataset.minimum || 0),
locked: row.classList.contains("is-locked")
}));
function showToast(message, type = "info", duration = 2400) {
window.WarpBoxUI?.toast?.(message, type, { target: toastTarget, duration });
}
function escapeHtml(value) {
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
}
function currentValue(row) {
if (!row.input) return row.element.dataset.original || "";
return String(row.input.value ?? "").trim();
}
function isDirty(row) {
return !row.locked && currentValue(row) !== (row.element.dataset.original || "");
}
function validateRow(row) {
if (row.locked || !row.input) {
row.element.classList.remove("is-invalid");
return true;
}
const value = currentValue(row);
let valid = true;
if (row.type === "size_gb") {
if (!/^\d+(?:\.\d+)?$/.test(value)) valid = false;
else if (Number(value) < row.minimum) valid = false;
} else if (row.type === "int" || row.type === "int64") {
if (!/^\d+$/.test(value)) valid = false;
else if (Number(value) < row.minimum) valid = false;
} else if (row.type === "bool") {
valid = value === "true" || value === "false";
}
row.element.classList.toggle("is-invalid", !valid);
return valid;
}
function rowMatchesSearch(row) {
const query = searchInput.value.trim().toLowerCase();
if (!query) return true;
const data = [
row.label,
row.envName,
row.element.dataset.description,
row.key
].join(" ").toLowerCase();
return data.includes(query);
}
function applyFilters() {
let visible = 0;
groups.forEach((group) => {
let groupVisible = 0;
group.querySelectorAll(".setting-row").forEach((node) => {
const row = rows.find((item) => item.element === node);
const categoryMatch = state.currentCategory === "all" || row.category === state.currentCategory;
const searchMatch = rowMatchesSearch(row);
const changedMatch = !state.showChangedOnly || isDirty(row);
const lockedMatch = !state.showLockedOnly || row.locked;
const show = categoryMatch && searchMatch && changedMatch && lockedMatch;
node.classList.toggle("is-hidden", !show);
if (show) {
visible += 1;
groupVisible += 1;
}
});
group.hidden = groupVisible === 0;
});
visibleCount.textContent = String(visible);
statusMiddle.textContent = `category: ${state.currentCategory}`;
}
function updateStats() {
let dirty = 0;
let editable = 0;
let locked = 0;
let invalid = 0;
rows.forEach((row) => {
if (row.locked) locked += 1;
else editable += 1;
if (isDirty(row)) dirty += 1;
if (!validateRow(row)) invalid += 1;
});
editableCount.textContent = String(editable);
lockedCount.textContent = String(locked);
unsavedCount.textContent = String(dirty);
dirtyChip.textContent = `${dirty} unsaved`;
dirtyChip.classList.toggle("is-dirty", dirty > 0);
saveButton.disabled = dirty === 0 || invalid > 0;
if (invalid > 0) {
actionSummary.textContent = `${invalid} invalid setting value(s) must be fixed before save.`;
statusLeft.textContent = "Invalid values";
statusRight.textContent = "fix before save";
} else if (dirty > 0) {
actionSummary.textContent = `${dirty} unsaved change(s) ready to save or export.`;
statusLeft.textContent = "Unsaved changes";
statusRight.textContent = "draft ready";
} else {
actionSummary.textContent = "No unsaved changes.";
statusLeft.textContent = "No unsaved changes";
statusRight.textContent = "admin only";
}
}
function updateView() {
updateStats();
applyFilters();
}
function setCategory(category) {
state.currentCategory = category;
categoryButtons.forEach((button) => button.classList.toggle("is-active", button.dataset.category === category));
applyFilters();
}
function draftValues() {
const values = {};
rows.forEach((row) => {
if (!row.locked && isDirty(row)) values[row.key] = currentValue(row);
});
return values;
}
function updateRowFromPayload(payload) {
const row = rows.find((item) => item.key === payload.key);
if (!row) return;
row.element.dataset.original = payload.value;
row.element.dataset.default = payload.default_value || "";
row.element.dataset.source = payload.source || "default";
row.element.dataset.sourceBadge = payload.source_badge || payload.source || "default";
row.element.dataset.description = payload.description || "";
row.element.dataset.minimum = String(payload.minimum || 0);
row.element.classList.toggle("is-locked", Boolean(payload.locked));
row.locked = Boolean(payload.locked);
row.minimum = Number(payload.minimum || 0);
if (row.input) {
row.input.value = payload.value ?? "";
row.input.disabled = Boolean(payload.locked);
}
if (row.hint) {
row.hint.textContent = payload.locked
? "Locked by environment or hard runtime implication."
: payload.type === "size_gb"
? "Use GB values. Decimals allowed, for example `0.5`."
: (payload.default_value ? `Default: ${payload.default_value}` : "");
}
if (row.badge) {
row.badge.textContent = payload.source_badge || payload.source || "default";
row.badge.className = `settings-badge ${badgeClass(payload.source_badge || payload.source || "default")}`;
}
rowData[payload.key] = payload;
}
function badgeClass(source) {
if (source === "default") return "badge-default";
if (source === "environment") return "badge-env";
if (source === "db override") return "badge-db";
return "badge-hard";
}
function hydrateRows(payloadRows) {
if (!Array.isArray(payloadRows)) return;
payloadRows.forEach(updateRowFromPayload);
updateView();
}
async function postJSON(url, body) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "Request failed");
}
return payload;
}
async function saveChanges() {
const values = draftValues();
if (Object.keys(values).length === 0) {
showToast("No changed settings to save", "info");
return;
}
try {
const payload = await postJSON("/admin/settings/save", { values });
hydrateRows(payload.rows);
showToast(payload.message || "Settings saved", payload.warnings?.length ? "warning" : "success");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function resetDefaults() {
if (!window.confirm("Reset all editable settings to built-in defaults?")) return;
try {
const payload = await postJSON("/admin/settings/reset", {});
hydrateRows(payload.rows);
showToast(payload.message || "Defaults restored", "success");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function resetSingleSetting(row) {
if (row.locked || !row.input) return;
if (isDirty(row)) {
row.input.value = row.element.dataset.original || "";
updateView();
showToast(`${row.label} draft cleared`);
return;
}
try {
const payload = await postJSON("/admin/settings/reset", { keys: [row.key] });
hydrateRows(payload.rows);
showToast(`${row.label} reset`, "success");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function exportSettings() {
try {
const response = await fetch("/admin/settings/export");
if (!response.ok) throw new Error("Could not export settings");
const payload = await response.json();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `warpbox-settings-${new Date().toISOString().replaceAll(":", "-")}.json`;
anchor.click();
URL.revokeObjectURL(url);
showToast("Settings JSON exported");
} catch (error) {
showToast(error.message, "error", 3200);
}
}
async function importSettingsFile(file) {
if (!file) return;
try {
const text = await file.text();
const payload = JSON.parse(text);
const result = await postJSON("/admin/settings/import", payload);
hydrateRows(result.rows);
showToast(result.message || "Settings imported", result.warnings?.length ? "warning" : "success", 3200);
} catch (error) {
showToast(error.message || "Could not import settings JSON", "error", 3200);
} finally {
importInput.value = "";
}
}
function discardUnsaved() {
rows.forEach((row) => {
if (!row.input) return;
row.input.value = row.element.dataset.original || "";
});
updateView();
showToast("Unsaved changes discarded");
}
function explainSources() {
window.WarpBoxUI?.openPopup?.(
"Setting Sources",
`
<ul>
<li><strong>default</strong>: built-in application value.</li>
<li><strong>environment</strong>: loaded from an environment variable.</li>
<li><strong>db override</strong>: saved from the admin settings page.</li>
<li><strong>hard env</strong>: visible here, but locked for safety.</li>
</ul>
`
);
}
function explainReset() {
window.WarpBoxUI?.openPopup?.(
"Reset Behavior",
`
<p>Reset clears saved admin overrides.</p>
<p>After reset, environment values win again. If no environment value exists, built-in defaults apply.</p>
`
);
}
function showRowInfo(row) {
window.WarpBoxUI?.openPopup?.(
row.label,
`
<p><strong>Environment variable:</strong> ${escapeHtml(row.envName || "n/a")}</p>
<p><strong>Current source:</strong> ${escapeHtml(row.badge?.textContent || row.element.dataset.sourceBadge || "default")}</p>
<p><strong>Description:</strong> ${escapeHtml(row.element.dataset.description || "No description available.")}</p>
${row.element.dataset.default ? `<p><strong>Default value:</strong> ${escapeHtml(row.element.dataset.default)}</p>` : ""}
`
);
}
async function runCommand(command) {
switch (command) {
case "save":
await saveChanges();
return;
case "export":
await exportSettings();
return;
case "import":
importInput.click();
return;
case "discard":
discardUnsaved();
return;
case "show-all":
state.showChangedOnly = false;
state.showLockedOnly = false;
applyFilters();
showToast("Showing all matching settings");
return;
case "show-changed":
state.showChangedOnly = !state.showChangedOnly;
if (state.showChangedOnly) state.showLockedOnly = false;
applyFilters();
showToast(state.showChangedOnly ? "Showing changed settings only" : "Showing all matching settings");
return;
case "show-locked":
state.showLockedOnly = !state.showLockedOnly;
if (state.showLockedOnly) state.showChangedOnly = false;
applyFilters();
showToast(state.showLockedOnly ? "Showing locked settings only" : "Showing all matching settings");
return;
case "reset-defaults":
await resetDefaults();
return;
case "reload":
window.location.reload();
return;
case "legend":
explainSources();
return;
case "reset-help":
explainReset();
return;
default:
showToast(`Unknown command: ${command}`, "warning");
}
}
rows.forEach((row) => {
row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView);
row.element.querySelector(".row-reset")?.addEventListener("click", () => resetSingleSetting(row));
row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row));
});
searchInput.addEventListener("input", applyFilters);
categoryButtons.forEach((button) => button.addEventListener("click", () => setCategory(button.dataset.category)));
saveButton.addEventListener("click", saveChanges);
exportButton.addEventListener("click", exportSettings);
importButton.addEventListener("click", () => importInput.click());
resetButton.addEventListener("click", resetDefaults);
importInput.addEventListener("change", (event) => importSettingsFile(event.target.files?.[0]));
popupClose?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
document.getElementById("modal-backdrop")?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.());
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
await runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", async (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
await saveChanges();
}
if (event.key === "F5") {
event.preventDefault();
window.location.reload();
}
if (event.key === "Escape") {
menuController.close();
window.WarpBoxUI?.closePopup?.();
}
});
updateView();
})();

304
static/js/admin/users.js Normal file
View File

@@ -0,0 +1,304 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const toastTarget = document.getElementById("toast");
const body = document.getElementById("users-body");
const search = document.getElementById("users-search");
const status = document.getElementById("users-status");
const role = document.getElementById("users-role-filter");
const sort = document.getElementById("users-sort");
const size = document.getElementById("users-size");
const masterCheck = document.getElementById("users-master-check");
const pageInfo = document.getElementById("users-page-info");
const visiblePill = document.getElementById("visible-pill");
const selectedPill = document.getElementById("users-selected-pill");
const prevBtn = document.getElementById("users-prev");
const nextBtn = document.getElementById("users-next");
const selectVisible = document.getElementById("select-visible");
const form = document.getElementById("users-form");
const modeInput = document.getElementById("users-mode");
const usernameInput = document.getElementById("users-username");
const emailInput = document.getElementById("users-email");
const roleInput = document.getElementById("users-role");
const planInput = document.getElementById("users-plan");
const statusLeft = document.getElementById("users-status-left");
if (!body || !search || !status || !role || !sort || !size) return;
const users = [
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" },
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" },
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
];
const state = { page: 1, selected: new Set() };
function toast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
return;
}
if (!toastTarget) return;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
}
function filtered() {
const query = search.value.trim().toLowerCase();
const statusFilter = status.value;
const roleFilter = role.value;
const sortBy = sort.value;
const rows = users.filter((user) => {
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
const matchesRole = roleFilter === "all" || user.role === roleFilter;
return matchesQuery && matchesStatus && matchesRole;
});
rows.sort((a, b) => {
if (sortBy === "createdDesc") return b.created.localeCompare(a.created);
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
return a.username.localeCompare(b.username);
});
return rows;
}
function paged(rows) {
const perPage = Number(size.value || 12);
const pages = Math.max(1, Math.ceil(rows.length / perPage));
if (state.page > pages) state.page = pages;
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * perPage;
return { rows: rows.slice(start, start + perPage), pages, start };
}
function statusPill(value) {
return `<span class="users-pill ${value}">${value}</span>`;
}
function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : "";
const row = document.createElement("tr");
row.innerHTML = `
<td><input type="checkbox" class="row-check"${checked}></td>
<td><div class="users-username"><strong>${user.username}</strong><span class="users-muted">${user.id}</span></div></td>
<td title="${user.email}">${user.email}</td>
<td>${statusPill(user.status)}</td>
<td>${user.role}</td>
<td>${user.plan}</td>
<td>${user.boxes}</td>
<td>${user.lastSeen}</td>
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></td>
`;
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id);
syncSelected();
syncMasterCheck();
});
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
toast(`Mock user preview: ${user.username}`);
});
return row;
}
function syncSelected() {
selectedPill.textContent = `${state.selected.size} selected`;
}
function syncMasterCheck() {
const checks = Array.from(body.querySelectorAll(".row-check"));
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
}
function renderStats() {
document.getElementById("stat-total").textContent = String(users.length);
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
}
function render() {
const rows = filtered();
const page = paged(rows);
body.innerHTML = "";
page.rows.forEach((user) => body.appendChild(renderRow(user)));
visiblePill.textContent = `${rows.length} visible`;
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
prevBtn.disabled = state.page <= 1;
nextBtn.disabled = state.page >= page.pages;
statusLeft.textContent = `Ready. ${rows.length} user rows in current filter.`;
syncSelected();
syncMasterCheck();
}
function clearFilters() {
search.value = "";
status.value = "all";
role.value = "all";
sort.value = "username";
state.page = 1;
render();
}
function applyBulk(nextStatus) {
const selected = users.filter((user) => state.selected.has(user.id));
if (!selected.length) {
toast("Select one or more users first", "warning");
return;
}
selected.forEach((user) => { user.status = nextStatus; });
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
renderStats();
render();
}
function runCommand(command) {
switch (command) {
case "invite":
modeInput.value = "invite";
toast("Invite mode selected");
break;
case "create":
modeInput.value = "create";
toast("Create mode selected");
break;
case "export":
toast("Mock CSV export complete");
break;
case "bulk-disable":
applyBulk("disabled");
break;
case "bulk-enable":
applyBulk("active");
break;
case "bulk-revoke":
toast("Mock session revocation queued");
break;
case "refresh":
toast("Users list refreshed");
render();
break;
case "pending-only":
status.value = "pending";
state.page = 1;
render();
break;
case "clear-filters":
clearFilters();
break;
case "policy-help":
toast("Policy editor will be added in user details later.");
break;
case "mock-note":
toast("Mock-only page: no backend writes yet.");
break;
default:
toast(`Mock action: ${command}`);
break;
}
}
[search, status, role, sort, size].forEach((el) => {
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1;
render();
});
});
prevBtn.addEventListener("click", () => {
state.page -= 1;
render();
});
nextBtn.addEventListener("click", () => {
state.page += 1;
render();
});
masterCheck.addEventListener("change", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (!checkbox) return;
checkbox.checked = masterCheck.checked;
const userID = row.querySelector(".users-muted")?.textContent || "";
if (masterCheck.checked) state.selected.add(userID);
else state.selected.delete(userID);
});
syncSelected();
});
selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const checkbox = row.querySelector(".row-check");
const userID = row.querySelector(".users-muted")?.textContent || "";
if (!checkbox) return;
checkbox.checked = true;
state.selected.add(userID);
});
syncSelected();
syncMasterCheck();
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const email = emailInput.value.trim();
const mode = modeInput.value;
if (!username || !email) {
toast("Username and email are required", "warning");
return;
}
users.unshift({
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
username,
email,
status: mode === "invite" ? "pending" : "active",
role: roleInput.value,
plan: planInput.value,
boxes: 0,
created: new Date().toISOString().slice(0, 10),
lastSeen: "never"
});
form.reset();
modeInput.value = "invite";
renderStats();
render();
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
event.preventDefault();
runCommand("invite");
}
});
renderStats();
render();
})();

View File

@@ -53,5 +53,46 @@ function renderTemplate(template, data = {}) {
});
}
return { toast, openPopup, closePopup, htmlEscape, renderTemplate };
function bindMenuBar(options = {}) {
const root = options.root || document;
const itemSelector = options.itemSelector || ".menu-item";
const buttonSelector = options.buttonSelector || ".menu-button";
const items = Array.from(root.querySelectorAll(itemSelector));
function close() {
items.forEach((item) => {
item.classList.remove("is-open");
item.querySelector(buttonSelector)?.setAttribute("aria-expanded", "false");
});
}
function open(item) {
close();
item.classList.add("is-open");
item.querySelector(buttonSelector)?.setAttribute("aria-expanded", "true");
}
items.forEach((item) => {
const button = item.querySelector(buttonSelector);
button?.addEventListener("click", (event) => {
event.stopPropagation();
const wasOpen = item.classList.contains("is-open");
close();
if (!wasOpen) open(item);
});
item.addEventListener("mouseenter", () => {
if (!root.querySelector(`${itemSelector}.is-open`)) return;
open(item);
});
});
document.addEventListener("click", (event) => {
if (!event.target.closest(itemSelector)) close();
});
return { close, open };
}
return { toast, openPopup, closePopup, htmlEscape, renderTemplate, bindMenuBar };
})();

View File

@@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-title">WarpBox Admin</h1>
</div>
</header>
<div class="win98-panel admin-panel">
<nav class="admin-nav">
<span>Signed in as {{ .CurrentUser }}</span>
<span class="admin-spacer"></span>
<form action="/admin/logout" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<button class="win98-button" type="submit">Logout</button>
</form>
</nav>
<div class="admin-grid">
<a class="win98-panel admin-link" href="/admin/boxes"><strong>Boxes</strong></a>
<a class="win98-panel admin-link" href="/admin/users"><strong>Users</strong></a>
<a class="win98-panel admin-link" href="/admin/tags"><strong>Tags</strong></a>
<a class="win98-panel admin-link" href="/admin/settings"><strong>Settings</strong></a>
</div>
</div>
</section>
</main>
</body>
</html>

321
templates/admin/alerts.html Normal file
View File

@@ -0,0 +1,321 @@
{{ define "admin/alerts.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Alerts</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/alerts.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<div class="win98-window admin-workspace-window" role="main">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Alerts</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<nav class="menu-bar" aria-label="Alerts toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh alerts</span><span class="shortcut">F5</span></button>
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible alerts</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Alerts</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="ack"><span>A</span><span>Acknowledge selected</span><span></span></button>
<button class="menu-action" type="button" data-command="close"><span>C</span><span>Close selected</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="open-only"><span>O</span><span>Show open only</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="help-codes"><span>?</span><span>Tracing and codes</span><span></span></button>
<button class="menu-action" type="button" data-command="help-meta"><span>I</span><span>Metadata preview</span><span></span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body alerts-page-body">
<section class="alerts-summary-grid" aria-label="Alerts summary">
<article class="alerts-stat-card is-danger">
<p class="alerts-stat-label">Open alerts</p>
<p class="alerts-stat-value" data-open-count>5</p>
<p class="alerts-stat-note">Requires attention</p>
</article>
<article class="alerts-stat-card is-warning">
<p class="alerts-stat-label">High severity</p>
<p class="alerts-stat-value" data-high-count>2</p>
<p class="alerts-stat-note">Escalate first</p>
</article>
<article class="alerts-stat-card is-info">
<p class="alerts-stat-label">Acknowledged</p>
<p class="alerts-stat-value" data-ack-count>3</p>
<p class="alerts-stat-note">Seen but not closed</p>
</article>
<article class="alerts-stat-card is-info">
<p class="alerts-stat-label">Closed today</p>
<p class="alerts-stat-value" data-closed-count>2</p>
<p class="alerts-stat-note">History stays lightweight</p>
</article>
</section>
<section class="alerts-content-grid">
<div class="alerts-column">
<section class="alerts-panel alerts-list-panel">
<div class="alerts-panel-header">
<div class="alerts-panel-title">Alert list <span class="alerts-panel-sub">search, filter, review</span></div>
<div class="alerts-panel-tools">
<button class="win98-button alerts-tool-button" type="button" data-command="ack">Acknowledge</button>
<button class="win98-button alerts-tool-button" type="button" data-command="close">Close</button>
</div>
</div>
<div class="alerts-panel-body">
<div class="alerts-toolbar-grid">
<input class="alerts-input" id="search-input" type="search" placeholder="Search title, code, trace or text">
<select class="alerts-select" id="severity-filter">
<option value="all" selected>All severities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<select class="alerts-select" id="status-filter">
<option value="all" selected>All statuses</option>
<option value="open">Open</option>
<option value="acked">Acknowledged</option>
<option value="closed">Closed</option>
</select>
<select class="alerts-select" id="source-filter">
<option value="all" selected>All groups</option>
<option value="thumbnails">Thumbnails</option>
<option value="storage">Storage</option>
<option value="uploads">Uploads</option>
<option value="auth">Auth</option>
</select>
<select class="alerts-select" id="sort-filter">
<option value="newest" selected>Newest first</option>
<option value="severity">Severity first</option>
<option value="oldest">Oldest first</option>
</select>
</div>
<div class="alerts-table-wrap">
<table class="alerts-table">
<thead>
<tr>
<th class="alerts-col-check"><input type="checkbox" id="select-all"></th>
<th>Title</th>
<th class="alerts-col-severity">Severity</th>
<th class="alerts-col-status">Status</th>
<th class="alerts-col-code">Code</th>
<th>Trace</th>
<th class="alerts-col-time">Created</th>
<th class="alerts-col-actions">Actions</th>
</tr>
</thead>
<tbody id="alerts-body">
<tr data-id="10" data-severity="high" data-status="open" data-group="storage" data-title="Storage connector unavailable" data-description="Primary local storage connector failed health check and new writes are paused." data-code="301" data-trace="storage.connector.health_failed" data-time="today 14:08" data-metadata='{"connector":"local-main","mode":"read_only","retry_in":"30s"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Storage connector unavailable</td>
<td><span class="alerts-pill high">high</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>301</td>
<td>storage.connector.health_failed</td>
<td>today 14:08</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="9" data-severity="medium" data-status="open" data-group="thumbnails" data-title="Thumbnail generation failed" data-description="Thumbnail generation failed for one uploaded image. Original file remains available." data-code="601" data-trace="thumbnail.generate.failed" data-time="today 13:40" data-metadata='{"box":"bx_49aa","file":"poster.png","worker":"thumb-2"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Thumbnail generation failed</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>601</td>
<td>thumbnail.generate.failed</td>
<td>today 13:40</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="8" data-severity="low" data-status="acked" data-group="uploads" data-title="Large upload nearing account cap" data-description="A user is close to their daily upload budget." data-code="124" data-trace="upload.quota.nearing_cap" data-time="today 12:58" data-metadata='{"user":"geo","used":"44 GB","limit":"50 GB"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Large upload nearing account cap</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>124</td>
<td>upload.quota.nearing_cap</td>
<td>today 12:58</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="7" data-severity="high" data-status="open" data-group="auth" data-title="Repeated admin login failures" data-description="Multiple failed admin login attempts were detected from the same source." data-code="211" data-trace="auth.admin.failed_login_burst" data-time="today 12:10" data-metadata='{"ip":"198.51.100.4","attempts":7,"window":"10m"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Repeated admin login failures</td>
<td><span class="alerts-pill high">high</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>211</td>
<td>auth.admin.failed_login_burst</td>
<td>today 12:10</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="6" data-severity="medium" data-status="acked" data-group="storage" data-title="Cleanup skipped locked files" data-description="Cleanup job encountered locked files and skipped them." data-code="342" data-trace="cleanup.skip.locked_files" data-time="today 10:22" data-metadata='{"count":3,"connector":"local-main"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Cleanup skipped locked files</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>342</td>
<td>cleanup.skip.locked_files</td>
<td>today 10:22</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="5" data-severity="low" data-status="closed" data-group="uploads" data-title="Archive completed with warnings" data-description="ZIP archive completed but excluded one unreadable temporary file." data-code="145" data-trace="archive.complete.with_warning" data-time="today 09:02" data-metadata='{"box":"bx_3901","skipped":1}'>
<td><input type="checkbox" class="row-check"></td>
<td>Archive completed with warnings</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>145</td>
<td>archive.complete.with_warning</td>
<td>today 09:02</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="4" data-severity="medium" data-status="open" data-group="uploads" data-title="Upload session expired mid-transfer" data-description="A long-running upload lost session validity before final commit." data-code="156" data-trace="upload.session.expired_mid_transfer" data-time="yesterday" data-metadata='{"user":"teo","partial_bytes":"1.2 GB"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Upload session expired mid-transfer</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>156</td>
<td>upload.session.expired_mid_transfer</td>
<td>yesterday</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="3" data-severity="low" data-status="closed" data-group="thumbnails" data-title="Thumbnail worker restarted" data-description="Thumbnail worker restarted after a normal watchdog recycle." data-code="602" data-trace="thumbnail.worker.restarted" data-time="yesterday" data-metadata='{"worker":"thumb-1","reason":"watchdog"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Thumbnail worker restarted</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>602</td>
<td>thumbnail.worker.restarted</td>
<td>yesterday</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="2" data-severity="medium" data-status="acked" data-group="auth" data-title="User invited without email delivery confirmation" data-description="Invite creation succeeded but email delivery confirmation was not returned." data-code="224" data-trace="auth.invite.delivery_unknown" data-time="2 days ago" data-metadata='{"user":"reo","provider":"smtp-primary"}'>
<td><input type="checkbox" class="row-check"></td>
<td>User invited without email delivery confirmation</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>224</td>
<td>auth.invite.delivery_unknown</td>
<td>2 days ago</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="1" data-severity="low" data-status="closed" data-group="storage" data-title="Secondary connector caught up" data-description="Delayed sync on a secondary storage connector completed successfully." data-code="329" data-trace="storage.secondary.sync_recovered" data-time="2 days ago" data-metadata='{"connector":"bucket-archive","lag":"0"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Secondary connector caught up</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>329</td>
<td>storage.secondary.sync_recovered</td>
<td>2 days ago</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<div class="alerts-column alerts-column-side">
<section class="alerts-panel">
<div class="alerts-panel-header">
<div class="alerts-panel-title">Alert details <span class="alerts-panel-sub">selected alert preview</span></div>
</div>
<div class="alerts-panel-body">
<ul class="alerts-info-list">
<li class="alerts-info-item"><strong>Title</strong><span id="detail-title">Storage connector unavailable</span></li>
<li class="alerts-info-item"><strong>Severity</strong><span id="detail-severity">high</span></li>
<li class="alerts-info-item"><strong>Status</strong><span id="detail-status">open</span></li>
<li class="alerts-info-item"><strong>Code</strong><span id="detail-code">301</span></li>
<li class="alerts-info-item"><strong>Trace</strong><span id="detail-trace">storage.connector.health_failed</span></li>
<li class="alerts-info-item"><strong>Created</strong><span id="detail-time">today 14:08</span></li>
<li class="alerts-info-item"><strong>Description</strong><span id="detail-description">Primary local storage connector failed health check and new writes are paused.</span></li>
</ul>
<div class="alerts-mini-note">
TO-DO: later, limited alert access should only show alerts scoped to the users permissions, tags, or groups.
</div>
</div>
</section>
<section class="alerts-panel">
<div class="alerts-panel-header">
<div class="alerts-panel-title">Metadata <span class="alerts-panel-sub">simple JSON preview</span></div>
<div class="alerts-panel-tools">
<button class="win98-button alerts-tool-button" type="button" data-command="copy-meta">Copy</button>
</div>
</div>
<div class="alerts-panel-body">
<pre class="alerts-json-box" id="detail-metadata">{
"connector": "local-main",
"mode": "read_only",
"retry_in": "30s"
}</pre>
</div>
</section>
<section class="alerts-panel alerts-actions-panel">
<div class="alerts-panel-header">
<div class="alerts-panel-title">Actions <span class="alerts-panel-sub">simple first version</span></div>
</div>
<div class="alerts-panel-body">
<div class="alerts-action-stack">
<button class="win98-button alerts-action-button" type="button" data-command="ack">Acknowledge selected</button>
<button class="win98-button alerts-action-button" type="button" data-command="close">Close selected</button>
<button class="win98-button alerts-action-button" type="button" data-command="refresh">Refresh alerts</button>
</div>
<div class="alerts-mini-note">
CURRENTLY_MOCKED_LEAVE_AS_IS: alerts use a lightweight lifecycle for now: open, acknowledged, closed.
</div>
</div>
</section>
</div>
</section>
</div>
<div class="alerts-footerbar">
<div class="alerts-footer-left">
<span class="alerts-status-pill" id="selected-count">Selected: 0</span>
<span class="alerts-status-pill">10 mocked alerts</span>
</div>
<div class="alerts-footer-right">
<button class="win98-button alerts-footer-button" type="button" data-command="ack">Acknowledge</button>
<button class="win98-button alerts-footer-button" type="button" data-command="close">Close</button>
</div>
</div>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/alerts.js"></script>
</body>
</html>
{{ end }}

245
templates/admin/boxes.html Normal file
View File

@@ -0,0 +1,245 @@
{{ define "admin/boxes.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Boxes</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/boxes.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<div class="win98-window admin-workspace-window" role="main">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Boxes</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<nav class="menu-bar" aria-label="Boxes toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh list</span><span class="shortcut">F5</span></button>
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible CSV</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="status-ready"><span>V</span><span>Show ready only</span><span></span></button>
<button class="menu-action" type="button" data-command="status-expired"><span>X</span><span>Show expired only</span><span></span></button>
<button class="menu-action" type="button" data-command="clear-filters"><span>C</span><span>Clear filters</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Boxes</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="expire"><span>!</span><span>Expire selected now</span><span></span></button>
<button class="menu-action" type="button" data-command="extend-day"><span>+</span><span>Extend selected by 24h</span><span></span></button>
<button class="menu-action" type="button" data-command="extend-week"><span>7</span><span>Extend selected by 7d</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="delete"><span>D</span><span>Delete selected</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="help-scope"><span>?</span><span>Ownership scope note</span><span></span></button>
<button class="menu-action" type="button" data-command="help-flags"><span>F</span><span>Flag meanings</span><span></span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body boxes-page-body">
<section class="boxes-summary-grid" aria-label="Boxes summary">
<article class="boxes-stat-card is-info">
<p class="boxes-stat-label">Total boxes</p>
<p class="boxes-stat-value" data-stat-total>0</p>
<p class="boxes-stat-note">All stored manifests and legacy boxes</p>
</article>
<article class="boxes-stat-card is-ok">
<p class="boxes-stat-label">Ready</p>
<p class="boxes-stat-value" data-stat-ready>0</p>
<p class="boxes-stat-note">Complete and still available</p>
</article>
<article class="boxes-stat-card is-warning">
<p class="boxes-stat-label">Uploading</p>
<p class="boxes-stat-value" data-stat-uploading>0</p>
<p class="boxes-stat-note">Still waiting on files</p>
</article>
<article class="boxes-stat-card is-danger">
<p class="boxes-stat-label">Expired / consumed</p>
<p class="boxes-stat-value" data-stat-expired>0</p>
<p class="boxes-stat-note">Needs cleanup or review</p>
</article>
</section>
<section class="boxes-hero-note">
<div>
<strong>Scope note.</strong>
<span>This page lists real stored boxes and real file state. Per-user ownership scoping is still pending backend account data.</span>
</div>
<div class="boxes-hero-tags">
<span class="boxes-hero-tag">real data</span>
<span class="boxes-hero-tag">real actions</span>
<span class="boxes-hero-tag">ownership TODO</span>
</div>
</section>
<section class="boxes-content-grid">
<div class="boxes-column">
<section class="boxes-panel">
<div class="boxes-panel-header">
<div class="boxes-panel-title">Box list <span class="boxes-panel-sub">search, filter, bulk actions</span></div>
<div class="boxes-panel-tools">
<button class="win98-button boxes-tool-button" type="button" data-command="refresh">Refresh</button>
<button class="win98-button boxes-tool-button" type="button" data-command="export">Export CSV</button>
<button class="win98-button boxes-tool-button" type="button" data-command="expire">Expire</button>
<button class="win98-button boxes-tool-button" type="button" data-command="extend-day">+24h</button>
<button class="win98-button boxes-tool-button is-danger" type="button" data-command="delete">Delete</button>
</div>
</div>
<div class="boxes-panel-body">
<div class="boxes-toolbar-grid">
<input class="boxes-input" id="boxes-search" type="search" placeholder="Search box id, file name, mime, retention">
<select class="boxes-select" id="boxes-status-filter">
<option value="all" selected>All statuses</option>
<option value="ready">Ready</option>
<option value="uploading">Uploading</option>
<option value="attention">Needs review</option>
<option value="expired">Expired</option>
<option value="consumed">Consumed</option>
<option value="legacy">Legacy</option>
</select>
<select class="boxes-select" id="boxes-flag-filter">
<option value="all" selected>All flags</option>
<option value="protected">Protected</option>
<option value="one-time">One-time</option>
<option value="zip off">ZIP off</option>
<option value="legacy">Legacy</option>
</select>
<select class="boxes-select" id="boxes-sort">
<option value="newest" selected>Newest first</option>
<option value="expires">Soonest expiry</option>
<option value="largest">Largest size</option>
<option value="name">Box id</option>
</select>
<select class="boxes-select" id="boxes-page-size">
<option value="10" selected>10 / page</option>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="9999">All rows</option>
</select>
</div>
<div class="boxes-table-wrap">
<table class="boxes-table">
<thead>
<tr>
<th class="boxes-col-check"><input type="checkbox" id="boxes-select-all"></th>
<th class="boxes-col-id">Box ID</th>
<th class="boxes-col-status">Status</th>
<th class="boxes-col-files">Files</th>
<th class="boxes-col-size">Size</th>
<th class="boxes-col-retention">Retention</th>
<th class="boxes-col-expires">Expires</th>
<th>Flags</th>
<th class="boxes-col-actions">Actions</th>
</tr>
</thead>
<tbody id="boxes-table-body"></tbody>
</table>
<div class="boxes-empty-state" id="boxes-empty-state" hidden>No boxes match current filters.</div>
</div>
<div class="boxes-footer-bar">
<span id="boxes-range-label">Showing 0-0 of 0</span>
<span id="boxes-selected-label">Selected: 0</span>
<div class="boxes-pagination">
<button class="win98-button boxes-page-button" type="button" id="boxes-prev-page">Prev</button>
<span id="boxes-page-label">Page 1 / 1</span>
<button class="win98-button boxes-page-button" type="button" id="boxes-next-page">Next</button>
</div>
</div>
</div>
</section>
</div>
<div class="boxes-column boxes-column-side">
<section class="boxes-panel">
<div class="boxes-panel-header">
<div class="boxes-panel-title">Box details <span class="boxes-panel-sub">selected box preview</span></div>
</div>
<div class="boxes-panel-body boxes-detail-body">
<ul class="boxes-info-list">
<li class="boxes-info-item"><strong>Box</strong><span id="detail-box-id">-</span></li>
<li class="boxes-info-item"><strong>Status</strong><span id="detail-status">-</span></li>
<li class="boxes-info-item"><strong>Created</strong><span id="detail-created">-</span></li>
<li class="boxes-info-item"><strong>Expires</strong><span id="detail-expires">-</span></li>
<li class="boxes-info-item"><strong>Retention</strong><span id="detail-retention">-</span></li>
<li class="boxes-info-item"><strong>Files</strong><span id="detail-files">-</span></li>
<li class="boxes-info-item"><strong>Size</strong><span id="detail-size">-</span></li>
<li class="boxes-info-item"><strong>Flags</strong><span id="detail-flags">-</span></li>
</ul>
<div class="boxes-action-stack">
<div class="boxes-action-grid">
<a class="win98-button boxes-action-button" id="detail-open" href="#" target="_blank" rel="noreferrer">Open</a>
<a class="win98-button boxes-action-button" id="detail-zip" href="#" target="_blank" rel="noreferrer">ZIP</a>
</div>
<div class="boxes-action-grid">
<button class="win98-button boxes-action-button" type="button" data-command="active-expire">Expire now</button>
<button class="win98-button boxes-action-button" type="button" data-command="active-extend-day">+24h</button>
</div>
<div class="boxes-action-grid">
<button class="win98-button boxes-action-button" type="button" data-command="active-extend-week">+7d</button>
<button class="win98-button boxes-action-button is-danger" type="button" data-command="active-delete">Delete</button>
</div>
</div>
</div>
</section>
<section class="boxes-panel boxes-files-panel">
<div class="boxes-panel-header">
<div class="boxes-panel-title">Files <span class="boxes-panel-sub">real file inventory</span></div>
</div>
<div class="boxes-panel-body">
<div class="boxes-file-list" id="detail-file-list"></div>
</div>
</section>
</div>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="boxes-footer-summary">0 boxes loaded</span>
<span id="boxes-footer-scope">scope: global admin view</span>
<span id="boxes-footer-zip">{{ if .ZipDownloadsOn }}zip downloads enabled{{ else }}zip downloads disabled{{ end }}</span>
</footer>
</div>
</div>
</div>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
<script id="boxes-data" type="application/json">{{ toJSON .Boxes }}</script>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/boxes.js"></script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,337 @@
{{ define "admin/dashboard.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Dashboard</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<!-- Dashboard Window -->
<div class="win98-window admin-dashboard-window" role="main">
<!-- Titlebar -->
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Account Control Panel</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<!-- Menu Bar -->
<nav class="menu-bar" aria-label="Dashboard toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh dashboard</span><span class="shortcut">F5</span></button>
<button class="menu-action" type="button" data-command="dashboard-snapshot"><span>S</span><span>Export dashboard snapshot</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="logout"><span>Q</span><span>Log out</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-scroll-to="alerts"><span>!</span><span>Go to alerts</span><span class="shortcut">Alt+A</span></button>
<button class="menu-action" type="button" data-scroll-to="recent-boxes"><span>B</span><span>Go to recent boxes</span><span class="shortcut">Alt+B</span></button>
<button class="menu-action" type="button" data-scroll-to="recent-activity"><span>T</span><span>Go to recent activity</span><span class="shortcut">Alt+R</span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="compact-mode"><span>C</span><span>Toggle compact density</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Boxes</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="show-all-boxes"><span>B</span><span>Show all boxes</span><span></span></button>
<button class="menu-action" type="button" data-command="export-boxes"><span>C</span><span>Export boxes CSV</span><span></span></button>
<button class="menu-action" type="button" data-command="cleanup-dry-run"><span>D</span><span>Cleanup dry run</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Alerts</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="show-all-alerts"><span>!</span><span>Show all alerts</span><span></span></button>
<button class="menu-action" type="button" data-command="dismiss-low-alerts"><span>L</span><span>Close all low alerts</span><span></span></button>
<button class="menu-action" type="button" data-command="export-alerts"><span>J</span><span>Export alerts JSON</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Admin</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="config-snapshot"><span>S</span><span>Config snapshot</span><span></span></button>
<button class="menu-action" type="button" data-command="support-summary"><span>?</span><span>Support summary</span><span></span></button>
<button class="menu-action" type="button" data-command="thumbnail-rebuild"><span>I</span><span>Queue thumbnail rebuild</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="open-users"><span>U</span><span>Open user manager</span><span></span></button>
<button class="menu-action" type="button" data-command="open-settings"><span>G</span><span>Open settings</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="alerts-help"><span>!</span><span>How alert tracing works</span><span></span></button>
<button class="menu-action" type="button" data-command="shortcuts"><span>K</span><span>Keyboard shortcuts</span><span></span></button>
<button class="menu-action" type="button" data-command="about"><span>W</span><span>About this mockup</span><span></span></button>
</div>
</div>
</nav>
<!-- Dashboard Body -->
<div class="dashboard-body">
<!-- Hero -->
<section class="dashboard-hero raised-panel" aria-labelledby="dashboardTitle">
<div class="hero-copy">
<h2 id="dashboardTitle">Dashboard</h2>
<p>At-a-glance account and admin overview for boxes, alerts, storage, users, and recent activity.</p>
</div>
<div class="hero-status" aria-label="System summary">
<div class="hero-status-row"><span>Guest uploads</span><strong class="status-ok">enabled</strong></div>
<div class="hero-status-row"><span>ZIP downloads</span><strong class="status-ok">enabled</strong></div>
<div class="hero-status-row"><span>One-time boxes</span><strong class="status-warn">limited</strong></div>
</div>
</section>
<!-- Stats -->
<section class="stats-grid" aria-label="Dashboard statistics">
<article class="stat-card sunken-panel is-info" id="activeBoxesCard">
<p class="stat-label">Active boxes</p>
<p class="stat-value">128</p>
<p class="stat-note"><span class="stat-note-pill">+12 today</span><span class="stat-note-pill">42 passworded</span></p>
</article>
<article class="stat-card sunken-panel is-info" id="storageCard">
<p class="stat-label">Storage available</p>
<p class="stat-value">812 GiB</p>
<p class="stat-note"><span class="stat-note-pill">188 GiB used</span><span class="stat-note-pill">1 TiB app cap</span><span class="stat-note-pill">local backend</span></p>
<span class="meter-track" aria-hidden="true"><span class="meter-bar" style="--meter: 18.8%"></span></span>
</article>
<article class="stat-card sunken-panel is-warning" id="alertsCard">
<p class="stat-label">Alerts</p>
<p class="stat-value"><span id="alertCountValue">15</span></p>
<p class="stat-note" id="alertStatNote"><span class="stat-note-pill">2 high</span><span class="stat-note-pill">5 medium</span><span class="stat-note-pill">8 low</span></p>
</article>
<article class="stat-card sunken-panel is-ok" id="usersCard">
<p class="stat-label">Users</p>
<p class="stat-value">19</p>
<p class="stat-note"><span class="stat-note-pill">15 active</span><span class="stat-note-pill">4 disabled</span><span class="stat-note-pill">admin-only</span></p>
</article>
</section>
<!-- Main Grid: Alerts, Boxes, Activity -->
<section class="dashboard-main-grid" aria-label="Dashboard panels">
<!-- Alerts -->
<article id="alerts" class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">!</span>
<h2>Alerts Inbox</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/admin/alerts">Show all</a>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel alerts-scroll" aria-label="Scrollable alerts inbox">
<div class="alert-list">
<div class="alert-row" data-severity="high" data-alert-title="Storage backend is almost full" data-alert-code="421" data-alert-meta='{"backend":"local","used_bytes":1009317314560,"available_bytes":45097156608,"configured_cap_bytes":1099511627776,"recommended_action":"run cleanup dry run or raise app cap"}'>
<span class="alert-severity">high</span>
<div><p class="alert-title">Storage backend is almost full</p><p class="alert-desc">The active local storage backend has less than 5% free capacity under the configured app cap.</p><p class="alert-trace">code 421, trace storage.local.capacity.high</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="high" data-alert-title="Disabled user has active sessions" data-alert-code="181" data-alert-meta='{"user":"old-operator","active_sessions":2,"recommended_action":"revoke sessions"}'>
<span class="alert-severity">high</span>
<div><p class="alert-title">Disabled user has active sessions</p><p class="alert-desc">A disabled account still has active sessions that should be revoked.</p><p class="alert-trace">code 181, trace auth.sessions.disabled_user_active</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="Expired boxes waiting cleanup" data-alert-code="301" data-alert-meta='{"expired_boxes":17,"oldest_expired_at":"2026-04-29T22:18:00+03:00","recommended_action":"run cleanup"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Expired boxes waiting cleanup</p><p class="alert-desc">Expired boxes are still present on disk and are eligible for cleanup.</p><p class="alert-trace">code 301, trace boxes.expiry.cleanup_pending</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="API key UI enabled but key backend missing" data-alert-code="711" data-alert-meta='{"ui_surface":"upload.api_key_input","backend_model":"missing","recommended_action":"hide UI or implement API keys"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">API key UI enabled but key backend missing</p><p class="alert-desc">The frontend advertises API key usage while server-side API key validation is not connected yet.</p><p class="alert-trace">code 711, trace api_keys.ui.backend_missing</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="Thumbnail queue is behind" data-alert-code="602" data-alert-meta='{"pending_thumbnails":44,"worker_interval_seconds":30,"recommended_action":"increase batch size or queue rebuild"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Thumbnail queue is behind</p><p class="alert-desc">The thumbnail worker has accumulated more pending previews than expected.</p><p class="alert-trace">code 602, trace thumbnails.worker.queue_lag</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="Large ZIP download failed" data-alert-code="502" data-alert-meta='{"box":"BX-7D20","zip_bytes":897300992,"attempt":1,"recommended_action":"retry manually or inspect files"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Large ZIP download failed</p><p class="alert-desc">A ZIP stream failed before the response finished.</p><p class="alert-trace">code 502, trace downloads.zip.stream_failed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="Guest quota close to daily cap" data-alert-code="231" data-alert-meta='{"ip":"192.0.2.44","used_today_bytes":1795162112,"daily_cap_bytes":2147483648,"recommended_action":"none"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Guest quota close to daily cap</p><p class="alert-desc">A guest IP is close to its configured daily upload cap.</p><p class="alert-trace">code 231, trace quotas.guest.daily.near_cap</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Thumbnail generation skipped" data-alert-code="601" data-alert-meta='{"box":"BX-9F31","file":"mockup.webp","reason":"unsupported decoder","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Thumbnail generation skipped</p><p class="alert-desc">A preview could not be generated for one image file.</p><p class="alert-trace">code 601, trace thumbnails.generate.skipped</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="One-time box downloaded" data-alert-code="511" data-alert-meta='{"box":"BX-440C","delete_after_success":true,"recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">One-time box downloaded</p><p class="alert-desc">A one-time ZIP handoff completed and the box was queued for deletion.</p><p class="alert-trace">code 511, trace downloads.one_time.completed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Settings override changed" data-alert-code="801" data-alert-meta='{"setting":"box_poll_interval_ms","source":"admin_override","recommended_action":"audit when audit log exists"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Settings override changed</p><p class="alert-desc">A runtime setting was changed through the settings UI.</p><p class="alert-trace">code 801, trace settings.override.changed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Password protected box created" data-alert-code="121" data-alert-meta='{"box":"BX-C2A8","owner":"maya","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Password protected box created</p><p class="alert-desc">A user created a password protected upload box.</p><p class="alert-trace">code 121, trace boxes.create.passworded</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Upload completed slowly" data-alert-code="222" data-alert-meta='{"box":"BX-88B4","duration_seconds":731,"recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Upload completed slowly</p><p class="alert-desc">An upload completed but exceeded the expected duration threshold.</p><p class="alert-trace">code 222, trace uploads.performance.slow_complete</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Session refreshed" data-alert-code="182" data-alert-meta='{"user":"admin","reason":"activity_refresh","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Session refreshed</p><p class="alert-desc">The current local session was refreshed after account activity.</p><p class="alert-trace">code 182, trace auth.session.refreshed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Box visited from share URL" data-alert-code="401" data-alert-meta='{"box":"BX-39C1","viewer":"guest","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Box visited from share URL</p><p class="alert-desc">A public box was opened through its normal shared page.</p><p class="alert-trace">code 401, trace boxes.share.opened</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Support summary generated" data-alert-code="901" data-alert-meta='{"requested_by":"admin","included_sections":["config","storage","alerts"],"recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Support summary generated</p><p class="alert-desc">A local support summary was generated from the toolbar.</p><p class="alert-trace">code 901, trace support.summary.generated</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
</div>
</div>
</div>
</article>
<!-- Recent Activity -->
<article id="recent-activity" class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">T</span>
<h2>Recent Activity</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/admin/dashboard#recent-activity">Show all</a>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel activity-scroll" aria-label="Scrollable recent activity list">
<div class="activity-list">
<div class="activity-row"><span class="activity-time">10:12</span><div><p class="activity-title">Box BX-9F31 completed upload</p><p class="activity-meta">4 files, password protected</p></div><span class="tag ok">box</span></div>
<div class="activity-row"><span class="activity-time">10:08</span><div><p class="activity-title">Alert 421 created</p><p class="activity-meta">storage.local.capacity.high</p></div><span class="tag danger">alert</span></div>
<div class="activity-row"><span class="activity-time">10:04</span><div><p class="activity-title">Guest created box BX-A71D</p><p class="activity-meta">retention 6 hours</p></div><span class="tag ok">upload</span></div>
<div class="activity-row"><span class="activity-time">09:58</span><div><p class="activity-title">Thumbnail worker skipped one image</p><p class="activity-meta">decoder unavailable for webp preview</p></div><span class="tag warn">thumbs</span></div>
<div class="activity-row"><span class="activity-time">09:51</span><div><p class="activity-title">Cleanup dry run opened</p><p class="activity-meta">17 expired boxes detected</p></div><span class="tag info">tools</span></div>
<div class="activity-row"><span class="activity-time">09:44</span><div><p class="activity-title">Large ZIP download completed</p><p class="activity-meta">BX-7D20, 12 files</p></div><span class="tag info">zip</span></div>
<div class="activity-row"><span class="activity-time">09:33</span><div><p class="activity-title">Settings snapshot requested</p><p class="activity-meta">admin opened config snapshot from toolbar</p></div><span class="tag info">settings</span></div>
<div class="activity-row"><span class="activity-time">09:21</span><div><p class="activity-title">Temporary cleanup skipped</p><p class="activity-meta">BX-1AA2 still had an active file handle</p></div><span class="tag warn">cleanup</span></div>
<div class="activity-row"><span class="activity-time">09:09</span><div><p class="activity-title">User maya uploaded 6 files</p><p class="activity-meta">91.9 MiB total</p></div><span class="tag ok">user</span></div>
<div class="activity-row"><span class="activity-time">08:55</span><div><p class="activity-title">Box BX-55E0 expired</p><p class="activity-meta">eligible for cleanup</p></div><span class="tag danger">expired</span></div>
<div class="activity-row"><span class="activity-time">08:42</span><div><p class="activity-title">One-time box created</p><p class="activity-meta">BX-440C, admin owner</p></div><span class="tag info">one-time</span></div>
<div class="activity-row"><span class="activity-time">08:31</span><div><p class="activity-title">User ana uploaded archive set</p><p class="activity-meta">7 files, 520.8 MiB</p></div><span class="tag ok">upload</span></div>
<div class="activity-row"><span class="activity-time">08:20</span><div><p class="activity-title">Guest accessed public box</p><p class="activity-meta">BX-39C1 viewed from share link</p></div><span class="tag info">access</span></div>
<div class="activity-row"><span class="activity-time">08:07</span><div><p class="activity-title">User mihai created box BX-F02A</p><p class="activity-meta">standard plan quota applied</p></div><span class="tag ok">quota</span></div>
<div class="activity-row"><span class="activity-time">07:54</span><div><p class="activity-title">Failed login attempt recorded</p><p class="activity-meta">admin account, single attempt</p></div><span class="tag warn">auth</span></div>
</div>
</div>
</div>
</article>
<!-- Recent Boxes (full width) -->
<article id="recent-boxes" class="win98-window section-window dashboard-span-2">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">B</span>
<h2>Recent Boxes</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/admin/dashboard#recent-boxes">Show all</a>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel boxes-scroll" aria-label="Scrollable recent boxes table">
<table class="box-table">
<thead><tr><th>Box</th><th>Owner</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Flags</th><th>Actions</th></tr></thead>
<tbody>
<tr><td>BX-9F31</td><td>maya</td><td>4</td><td>91.9 MiB</td><td>10:12</td><td>5h 41m</td><td><span class="tag ok">complete</span> <span class="tag info">password</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-9F31">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-9F31">Manage</a></div></td></tr>
<tr><td>BX-A71D</td><td>guest</td><td>12</td><td>1.8 GiB</td><td>10:04</td><td>6h 00m</td><td><span class="tag warn">large</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-A71D">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-A71D">Manage</a></div></td></tr>
<tr><td>BX-20BD</td><td>operator</td><td>2</td><td>8.4 MiB</td><td>09:58</td><td>1d 12h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-20BD">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-20BD">Manage</a></div></td></tr>
<tr><td>BX-7D20</td><td>admin</td><td>12</td><td>856.3 MiB</td><td>09:44</td><td>23h 11m</td><td><span class="tag danger">zip failed</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-7D20">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-7D20">Manage</a></div></td></tr>
<tr><td>BX-1AA2</td><td>guest</td><td>1</td><td>4.7 GiB</td><td>09:21</td><td>expired</td><td><span class="tag danger">locked</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-1AA2">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-1AA2">Manage</a></div></td></tr>
<tr><td>BX-C2A8</td><td>maya</td><td>6</td><td>24.8 MiB</td><td>09:09</td><td>2d 03h</td><td><span class="tag ok">complete</span> <span class="tag info">password</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-C2A8">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-C2A8">Manage</a></div></td></tr>
<tr><td>BX-55E0</td><td>guest</td><td>1</td><td>4.2 MiB</td><td>08:55</td><td>expired</td><td><span class="tag danger">expired</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-55E0">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-55E0">Manage</a></div></td></tr>
<tr><td>BX-440C</td><td>admin</td><td>3</td><td>63.0 MiB</td><td>08:42</td><td>2d 00h</td><td><span class="tag ok">complete</span> <span class="tag info">one-time</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-440C">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-440C">Manage</a></div></td></tr>
<tr><td>BX-88B4</td><td>ana</td><td>7</td><td>520.8 MiB</td><td>08:31</td><td>5d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-88B4">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-88B4">Manage</a></div></td></tr>
<tr><td>BX-39C1</td><td>guest</td><td>2</td><td>23.1 MiB</td><td>08:20</td><td>16h 00m</td><td><span class="tag info">public</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-39C1">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-39C1">Manage</a></div></td></tr>
<tr><td>BX-F02A</td><td>mihai</td><td>5</td><td>108.6 MiB</td><td>08:07</td><td>4d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-F02A">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-F02A">Manage</a></div></td></tr>
<tr><td>BX-ABC4</td><td>guest</td><td>1</td><td>755 KiB</td><td>07:54</td><td>3h 00m</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-ABC4">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-ABC4">Manage</a></div></td></tr>
<tr><td>BX-74E9</td><td>operator</td><td>10</td><td>987.3 MiB</td><td>07:41</td><td>7d 00h</td><td><span class="tag info">bulk</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-74E9">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-74E9">Manage</a></div></td></tr>
<tr><td>BX-218B</td><td>daniel</td><td>3</td><td>44.0 MiB</td><td>07:28</td><td>1d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-218B">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-218B">Manage</a></div></td></tr>
<tr><td>BX-00FE</td><td>guest</td><td>2</td><td>13.7 MiB</td><td>07:12</td><td>2h 00m</td><td><span class="tag warn">soon</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-00FE">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-00FE">Manage</a></div></td></tr>
</tbody>
</table>
</div>
</div>
</article>
</section>
</div>
<!-- Statusbar -->
<div class="win98-statusbar admin-dashboard-statusbar">
<span id="statusText">Ready</span>
<span>WarpBox mock v5</span>
<span>Single-window dashboard</span>
</div>
</div>
</div>
</div>
<!-- Modal backdrop -->
<div class="modal-backdrop" data-modal-backdrop></div>
<!-- Alert metadata popup -->
<aside class="popup-window win98-window" data-alert-modal aria-label="Alert metadata" aria-hidden="true">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">!</span>
<h2 id="modalTitle">Alert Metadata</h2>
</div>
<button class="win98-control" type="button" data-close-modal>x</button>
</div>
<div class="popup-body sunken-panel">
<pre class="metadata-pre" id="modalMeta">{}</pre>
</div>
</aside>
<!-- Toast -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/dashboard.js"></script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,67 @@
{{ define "admin/login.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Login</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/login.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window login-window" aria-labelledby="login-window-title">
<header class="win98-titlebar login-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="login-window-title">WarpBox Administration</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<span class="win98-control">_</span>
<span class="win98-control"></span>
<span class="win98-control">×</span>
</div>
</header>
<form class="login-form" action="/admin/login" method="post">
<div class="win98-panel login-panel">
<div class="login-alert" role="alert">
<img src="/static/img/icons/Windows Icons - PNG/shell32.dll_210_21001.png" alt="" aria-hidden="true">
<p>Enter the administrator username and password to access the control panel.</p>
</div>
<label class="login-row" for="admin-username">
<span>User name</span>
<input id="admin-username" class="login-input" type="text" name="username" autocomplete="username" autofocus>
</label>
<label class="login-row" for="admin-password">
<span>Password</span>
<input id="admin-password" class="login-input" type="password" name="password" autocomplete="current-password">
</label>
{{ if .ErrorMessage }}
<p class="login-error">{{ .ErrorMessage }}</p>
{{ end }}
</div>
<footer class="login-actions">
<button class="win98-button" type="submit">OK</button>
<a class="win98-button" href="/">Cancel</a>
</footer>
<div class="win98-statusbar login-statusbar">
<span>Administrator authentication</span>
<span>WarpBox</span>
</div>
</form>
</section>
</main>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,19 @@
{{ define "admin/header.html" }}
<header class="admin-taskbar" aria-label="Admin navigation">
<a class="admin-start-button" href="/admin/dashboard">
<span class="admin-start-logo">W</span>
<span>WarpBox</span>
</a>
<nav class="admin-taskbar-nav" aria-label="Primary">
<a class="admin-taskbar-button{{ if eq .ActivePage "dashboard" }} is-active{{ end }}" href="/admin/dashboard">Dashboard</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "alerts" }} is-active{{ end }}" href="/admin/alerts">Alerts</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "boxes" }} is-active{{ end }}" href="/admin/boxes">Boxes</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "users" }} is-active{{ end }}" href="/admin/users">Users</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
</nav>
<div class="admin-taskbar-session" aria-label="Admin session summary">
<a class="admin-alert-chip is-warning" href="/admin/alerts" id="topAlertChip">! 15 alerts</a>
<span class="admin-session-chip">signed in: {{ .AdminUsername }}</span>
</div>
</header>
{{ end }}

View File

@@ -0,0 +1,244 @@
{{ define "admin/settings.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Settings</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/settings.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<div class="win98-window admin-workspace-window" role="main">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Settings</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<nav class="menu-bar" aria-label="Settings toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="save"><span>S</span><span>Save overrides</span><span class="shortcut">Ctrl+S</span></button>
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export settings JSON</span><span></span></button>
<button class="menu-action" type="button" data-command="import"><span>I</span><span>Import settings JSON</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="discard"><span>D</span><span>Discard unsaved changes</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="show-all"><span>A</span><span>Show all settings</span><span></span></button>
<button class="menu-action" type="button" data-command="show-changed"><span>C</span><span>Show changed only</span><span></span></button>
<button class="menu-action" type="button" data-command="show-locked"><span>L</span><span>Show locked only</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Settings</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="reset-defaults"><span>R</span><span>Reset editable to defaults</span><span></span></button>
<button class="menu-action" type="button" data-command="reload"><span>F</span><span>Reload current values</span><span class="shortcut">F5</span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="legend"><span>?</span><span>Explain sources</span><span></span></button>
<button class="menu-action" type="button" data-command="reset-help"><span>!</span><span>Reset semantics</span><span></span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body settings-page-body">
<section class="settings-summary-grid" aria-label="Settings summary">
<article class="settings-stat-card is-info">
<p class="settings-stat-label">Visible settings</p>
<p class="settings-stat-value" id="visibleCount">{{ len .Rows }}</p>
<p class="settings-stat-note">Filtered by search and category</p>
</article>
<article class="settings-stat-card is-ok">
<p class="settings-stat-label">Editable</p>
<p class="settings-stat-value" id="editableCount">0</p>
<p class="settings-stat-note">Runtime override supported</p>
</article>
<article class="settings-stat-card is-warning">
<p class="settings-stat-label">Unsaved</p>
<p class="settings-stat-value" id="unsavedCount">0</p>
<p class="settings-stat-note">Draft changes in browser</p>
</article>
<article class="settings-stat-card is-danger">
<p class="settings-stat-label">Locked</p>
<p class="settings-stat-value" id="lockedCount">0</p>
<p class="settings-stat-note">Environment only</p>
</article>
</section>
<section class="settings-main-grid">
<aside class="settings-sidebar-panel">
<section class="settings-panel settings-sidebar">
<div class="settings-panel-header">
<div class="settings-panel-title">Categories <span class="settings-panel-sub">search and scope</span></div>
</div>
<div class="settings-panel-body">
<div class="settings-search">
<label for="settingsSearch">Search</label>
<input class="settings-input" id="settingsSearch" type="search" placeholder="Search label, env var, description">
</div>
<ul class="settings-category-list" id="categoryList">
{{ range .Categories }}
<li>
<button class="settings-category-button{{ if eq .Key "all" }} is-active{{ end }}" type="button" data-category="{{ .Key }}">
<span>{{ .Icon }}</span>
<span>{{ .Label }}</span>
<span class="settings-category-count">{{ .Count }}</span>
</button>
</li>
{{ end }}
</ul>
</div>
</section>
</aside>
<section class="settings-workbench">
<section class="settings-hero-panel">
<div class="settings-hero-copy">
<h2>Administrative runtime settings</h2>
<p>Edit safe runtime overrides without hiding where each value came from. Hard storage and security-sensitive environment settings stay visible but locked.</p>
</div>
<div class="settings-hero-legend">
<div class="settings-legend-row"><span class="settings-badge badge-default">default</span><span>Built-in application value</span></div>
<div class="settings-legend-row"><span class="settings-badge badge-env">environment</span><span>Loaded from env</span></div>
<div class="settings-legend-row"><span class="settings-badge badge-db">db override</span><span>Saved from admin UI</span></div>
<div class="settings-legend-row"><span class="settings-badge badge-hard">hard env</span><span>Visible, not editable here</span></div>
</div>
</section>
<section class="settings-panel">
<div class="settings-panel-header">
<div class="settings-panel-title">Settings grid <span class="settings-panel-sub">edit, inspect, import, export</span></div>
<div class="settings-panel-tools">
<span class="settings-dirty-chip" id="dirtyChip">0 unsaved</span>
<button class="win98-button settings-tool-button" id="exportButton" type="button">Export JSON</button>
<button class="win98-button settings-tool-button" id="importButton" type="button">Import JSON</button>
<button class="win98-button settings-tool-button" id="resetButton" type="button">Reset Defaults</button>
<button class="win98-button settings-tool-button" id="saveButton" type="button" disabled>Save</button>
</div>
</div>
<div class="settings-panel-body">
<div class="settings-action-summary" id="actionSummary">No unsaved changes.</div>
<div class="settings-groups" id="settingsGroups">
{{ range .Categories }}
{{ if ne .Key "all" }}
<section class="settings-group" data-category="{{ .Key }}">
<div class="settings-group-title">{{ .Label }}</div>
<div class="settings-table-wrap">
<table class="settings-table">
<thead>
<tr>
<th>Setting</th>
<th>Source</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr class="setting-row{{ if .Locked }} is-locked{{ end }}"
data-key="{{ .Key }}"
data-label="{{ .Label }}"
data-category="{{ .Category }}"
data-type="{{ .Type }}"
data-original="{{ .Value }}"
data-default="{{ .DefaultValue }}"
data-env-name="{{ .EnvName }}"
data-source="{{ .Source }}"
data-source-badge="{{ .SourceBadge }}"
data-description="{{ .Description }}"
data-minimum="{{ .Minimum }}">
<td>
<div class="setting-meta">
<strong>{{ .Label }}</strong>
<code>{{ .EnvName }}</code>
</div>
</td>
<td>
<span class="settings-badge{{ if eq .SourceBadge "default" }} badge-default{{ else if eq .SourceBadge "environment" }} badge-env{{ else if eq .SourceBadge "db override" }} badge-db{{ else }} badge-hard{{ end }}" data-role="source-badge">{{ .SourceBadge }}</span>
</td>
<td>
<div class="setting-control">
{{ if eq .Type "bool" }}
<select class="settings-select setting-input"{{ if .Locked }} disabled{{ end }}>
<option value="true"{{ if eq .Value "true" }} selected{{ end }}>true</option>
<option value="false"{{ if eq .Value "false" }} selected{{ end }}>false</option>
</select>
{{ else }}
<div class="setting-input-row">
<input class="settings-input setting-input" type="text" value="{{ .Value }}"{{ if .Locked }} disabled{{ end }}>
{{ if eq .Type "size_gb" }}<span class="settings-badge badge-default">GB</span>{{ end }}
</div>
{{ end }}
<div class="setting-hint" data-role="hint">{{ if .Locked }}Locked by environment or hard runtime implication.{{ else if eq .Type "size_gb" }}Use GB values. Decimals allowed, for example `0.5`.{{ else if .DefaultValue }}Default: {{ .DefaultValue }}{{ end }}</div>
</div>
</td>
<td class="setting-actions">
<button class="win98-button settings-mini-button row-reset" type="button"{{ if .Locked }} disabled{{ end }}>Reset</button>
<button class="win98-button settings-mini-button row-info" type="button">Info</button>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</section>
{{ end }}
{{ end }}
</div>
</div>
</section>
</section>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="statusLeft">No unsaved changes</span>
<span id="statusMiddle">category: all</span>
<span id="statusRight">admin only</span>
</footer>
</div>
</div>
</div>
<div id="modal-backdrop" class="settings-modal-backdrop"></div>
<div id="doc-popup" class="settings-popup" role="dialog" aria-modal="true" aria-labelledby="doc-popup-title">
<div class="settings-popup-titlebar">
<strong id="doc-popup-title">Details</strong>
<button class="win98-button settings-popup-close" id="doc-popup-close" type="button">Close</button>
</div>
<div class="settings-popup-body" id="doc-popup-body"></div>
</div>
<input id="settingsImportInput" type="file" accept="application/json,.json" hidden>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
<script id="settings-rows" type="application/json">{{ toJSON .RowsJSON }}</script>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/settings.js"></script>
</body>
</html>
{{ end }}

195
templates/admin/users.html Normal file
View File

@@ -0,0 +1,195 @@
{{ define "admin/users.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Users</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/users.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<div class="win98-window admin-workspace-window" role="main">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Users</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<nav class="menu-bar" aria-label="Users toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="invite"><span>I</span><span>Invite user</span><span>Ctrl+I</span></button>
<button class="menu-action" type="button" data-command="create"><span>C</span><span>Create local user</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible CSV</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Users</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="bulk-disable"><span>D</span><span>Disable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-enable"><span>U</span><span>Enable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-revoke"><span>R</span><span>Revoke sessions</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="refresh"><span>F</span><span>Refresh list</span><span>F5</span></button>
<button class="menu-action" type="button" data-command="pending-only"><span>P</span><span>Show pending invites</span><span></span></button>
<button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="policy-help"><span>?</span><span>User policy notes</span><span></span></button>
<button class="menu-action" type="button" data-command="mock-note"><span>M</span><span>Mock-only notes</span><span></span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body users-page-body">
<section class="users-hero">
<div>
<h2>Accounts, invites, and access</h2>
<p>Mock administrative users view for creation, invitation, filtering, and safe bulk actions.</p>
</div>
<div class="users-hero-actions">
<button class="win98-button users-action-button" type="button" data-command="invite">Invite user</button>
<button class="win98-button users-action-button" type="button" data-command="create">Create local user</button>
<button class="win98-button users-action-button" type="button" data-command="export">Export CSV</button>
<button class="win98-button users-action-button" type="button" data-command="policy-help">Policy notes</button>
</div>
</section>
<section class="users-summary-grid">
<article class="users-stat-card is-info"><p>Total users</p><strong id="stat-total">0</strong></article>
<article class="users-stat-card is-ok"><p>Active</p><strong id="stat-active">0</strong></article>
<article class="users-stat-card is-warning"><p>Pending invites</p><strong id="stat-pending">0</strong></article>
<article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article>
</section>
<section class="users-main-grid">
<section class="users-panel">
<div class="users-panel-header">
<div class="users-panel-title">Create or invite <span>mock only</span></div>
</div>
<div class="users-panel-body">
<form id="users-form" class="users-form-grid">
<label class="users-field">Mode
<select class="users-select" id="users-mode">
<option value="invite">Send invite</option>
<option value="create">Create local user</option>
</select>
</label>
<label class="users-field">Username<input class="users-input" id="users-username" type="text" autocomplete="off"></label>
<label class="users-field">Email<input class="users-input" id="users-email" type="email" autocomplete="off"></label>
<div class="users-row-two">
<label class="users-field">Role
<select class="users-select" id="users-role">
<option value="uploader">uploader</option>
<option value="operator">operator</option>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</label>
<label class="users-field">Plan
<select class="users-select" id="users-plan">
<option value="standard">standard</option>
<option value="trusted">trusted</option>
<option value="guest-like">guest-like</option>
<option value="unlimited">unlimited</option>
</select>
</label>
</div>
<label class="users-check"><input type="checkbox" id="users-send-setup" checked>Send setup instructions</label>
<div class="users-form-actions">
<button class="win98-button users-action-button" type="reset">Clear</button>
<button class="win98-button users-action-button" type="submit">Apply</button>
</div>
</form>
</div>
</section>
<section class="users-panel">
<div class="users-panel-header">
<div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div>
<div class="users-panel-tools">
<button class="win98-button users-tool-button" type="button" id="select-visible">Select visible</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-disable">Disable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-enable">Enable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-revoke">Revoke</button>
</div>
</div>
<div class="users-panel-body users-list-body">
<div class="users-toolbar-grid">
<input class="users-input" id="users-search" type="search" placeholder="Search username or email">
<select class="users-select" id="users-status"><option value="all">all statuses</option><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select>
<select class="users-select" id="users-role-filter"><option value="all">all roles</option><option value="admin">admin</option><option value="operator">operator</option><option value="uploader">uploader</option><option value="viewer">viewer</option></select>
<select class="users-select" id="users-sort"><option value="username">sort username</option><option value="createdDesc">newest first</option><option value="lastSeenDesc">last seen</option><option value="boxesDesc">box count</option></select>
<select class="users-select" id="users-size"><option value="8">8 rows</option><option value="12" selected>12 rows</option><option value="20">20 rows</option></select>
</div>
<div class="users-table-wrap">
<table class="users-table">
<thead>
<tr>
<th class="users-col-check"><input type="checkbox" id="users-master-check"></th>
<th>User</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Plan</th>
<th>Boxes</th>
<th>Last seen</th>
<th class="users-col-actions">Actions</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
<div class="users-pagination">
<span id="users-page-info">Page 1</span>
<span id="users-selected-pill">0 selected</span>
<div>
<button class="win98-button users-page-button" type="button" id="users-prev">Prev</button>
<button class="win98-button users-page-button" type="button" id="users-next">Next</button>
</div>
</div>
</div>
</section>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="users-status-left">Ready. Client-side mock data only.</span>
<span>server paging planned</span>
<span>admin only</span>
</footer>
</div>
</div>
</div>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/users.js"></script>
</body>
</html>
{{ end }}

View File

@@ -1,62 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Boxes</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-boxes-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-boxes-title">Boxes</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
<div class="admin-summary">
<span class="win98-panel">Boxes: {{ .TotalBoxes }}</span>
<span class="win98-panel">Storage: {{ .TotalStorage }}</span>
<span class="win98-panel">Expired: {{ .ExpiredBoxes }}</span>
</div>
<table class="admin-table">
<thead>
<tr>
<th>Box ID</th>
<th>Files</th>
<th>Size</th>
<th>Created</th>
<th>Expires</th>
<th>Flags</th>
</tr>
</thead>
<tbody>
{{ range .Boxes }}
<tr>
<td><a href="/box/{{ .ID }}">{{ .ID }}</a></td>
<td>{{ .FileCount }}</td>
<td>{{ .TotalSizeLabel }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ .ExpiresAt }}</td>
<td>
{{ if .Expired }}expired {{ end }}
{{ if .OneTimeDownload }}one-time {{ end }}
{{ if .PasswordProtected }}password {{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="6">No boxes found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Login</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-login-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-login-title">WarpBox Admin</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
{{ if .AdminLoginEnabled }}
<form class="admin-form" action="/admin/login" method="post">
<label class="admin-form-row">
<span>Username</span>
<input name="username" autocomplete="username" required>
</label>
<label class="admin-form-row">
<span>Password</span>
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="win98-button" type="submit">Login</button>
</form>
{{ else }}
<p>Administrator login is disabled. Set WARPBOX_ADMIN_PASSWORD and restart to bootstrap the first admin user.</p>
{{ end }}
</div>
</section>
</main>
</body>
</html>

View File

@@ -1,11 +0,0 @@
{{ define "admin_nav" }}
<nav class="admin-nav">
{{ if ne .AdminSection "dashboard" }}<a class="win98-button" href="/admin">Admin</a>{{ end }}
{{ if ne .AdminSection "boxes" }}<a class="win98-button" href="/admin/boxes">Boxes</a>{{ end }}
{{ if ne .AdminSection "users" }}<a class="win98-button" href="/admin/users">Users</a>{{ end }}
{{ if ne .AdminSection "tags" }}<a class="win98-button" href="/admin/tags">Tags</a>{{ end }}
{{ if ne .AdminSection "settings" }}<a class="win98-button" href="/admin/settings">Settings</a>{{ end }}
<span class="admin-spacer"></span>
<span>{{ .CurrentUser }}</span>
</nav>
{{ end }}

View File

@@ -1,66 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Settings</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-settings-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-settings-title">Settings</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
<form class="admin-form" action="/admin/settings" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<table class="admin-table">
<thead>
<tr>
<th>Setting</th>
<th>Value</th>
<th>Source</th>
<th>Env</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr>
<td>{{ .Definition.Label }}{{ if .Definition.HardLimit }} (hard){{ end }}</td>
<td>
{{ if and $.OverridesAllowed .Definition.Editable }}
{{ if eq .Definition.Type "bool" }}
<input type="checkbox" name="{{ .Definition.Key }}" value="true" {{ if eq .Value "true" }}checked{{ end }}>
{{ else }}
<input name="{{ .Definition.Key }}" value="{{ .Value }}" inputmode="numeric">
{{ end }}
{{ else }}
{{ .Value }}
{{ end }}
</td>
<td>{{ .Source }}</td>
<td>{{ .Definition.EnvName }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ if .OverridesAllowed }}
<button class="win98-button" type="submit">Save Settings</button>
{{ end }}
</form>
</div>
</section>
</main>
</body>
</html>

View File

@@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Tags</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-tags-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-tags-title">Tags</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
<form class="admin-form win98-panel" action="/admin/tags" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<label class="admin-form-row">
<span>Name</span>
<input name="name" required>
</label>
<label class="admin-form-row">
<span>Description</span>
<textarea name="description" rows="2"></textarea>
</label>
<div class="admin-checks">
<label><input type="checkbox" name="admin_access" value="true"><span>Admin access</span></label>
<label><input type="checkbox" name="admin_users_manage" value="true"><span>Manage users</span></label>
<label><input type="checkbox" name="admin_settings_manage" value="true"><span>Manage settings</span></label>
<label><input type="checkbox" name="admin_boxes_view" value="true"><span>View boxes</span></label>
<label><input type="checkbox" name="upload_allowed" value="true"><span>Upload allowed</span></label>
<label><input type="checkbox" name="zip_download_allowed" value="true"><span>ZIP allowed</span></label>
<label><input type="checkbox" name="one_time_download_allowed" value="true"><span>One-time allowed</span></label>
<label><input type="checkbox" name="renewable_allowed" value="true"><span>Renewable allowed</span></label>
</div>
<label class="admin-form-row">
<span>Max file size bytes</span>
<input name="max_file_size_bytes" inputmode="numeric">
</label>
<label class="admin-form-row">
<span>Max box size bytes</span>
<input name="max_box_size_bytes" inputmode="numeric">
</label>
<label class="admin-form-row">
<span>Allowed expiry seconds</span>
<input name="allowed_expiry_seconds" placeholder="600, 3600, 86400">
</label>
<button class="win98-button" type="submit">Create Tag</button>
</form>
<table class="admin-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Flags</th>
<th>Max file</th>
<th>Max box</th>
<th>Expiry seconds</th>
</tr>
</thead>
<tbody>
{{ range .Tags }}
<tr>
<td>{{ .Name }} {{ if .Protected }}(protected){{ end }}</td>
<td>{{ .Description }}</td>
<td>
{{ if .AdminAccess }}admin {{ end }}
{{ if .UploadAllowed }}upload {{ end }}
{{ if .ZipDownloadAllowed }}zip {{ end }}
{{ if .OneTimeDownloadAllowed }}one-time {{ end }}
{{ if .RenewableAllowed }}renew {{ end }}
</td>
<td>{{ .MaxFileSizeBytes }}</td>
<td>{{ .MaxBoxSizeBytes }}</td>
<td>{{ .AllowedExpirySeconds }}</td>
</tr>
{{ else }}
<tr><td colspan="6">No tags found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>

View File

@@ -1,87 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WarpBox Admin Users</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
<main>
<section class="win98-window admin-window" aria-labelledby="admin-users-title">
<header class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1 id="admin-users-title">Users</h1>
</div>
</header>
<div class="win98-panel admin-panel">
{{ template "admin_nav" . }}
{{ if .Error }}
<p class="admin-error">{{ .Error }}</p>
{{ end }}
<form class="admin-form win98-panel" action="/admin/users" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<label class="admin-form-row">
<span>Username</span>
<input name="username" required>
</label>
<label class="admin-form-row">
<span>Email</span>
<input name="email" type="email">
</label>
<label class="admin-form-row">
<span>Password</span>
<input name="password" type="password" autocomplete="new-password" required>
</label>
<div class="admin-checks">
{{ range .Tags }}
<label>
<input type="checkbox" name="tag_ids" value="{{ .ID }}">
<span>{{ .Name }}</span>
</label>
{{ end }}
</div>
<button class="win98-button" type="submit">Create User</button>
</form>
<table class="admin-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Tags</th>
<th>Created</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{{ range .Users }}
<tr>
<td>{{ .Username }}</td>
<td>{{ .Email }}</td>
<td>{{ .Tags }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ if .Disabled }}Disabled{{ else }}Active{{ end }}</td>
<td>
<form action="/admin/users" method="post">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="action" value="toggle_disabled">
<input type="hidden" name="user_id" value="{{ .ID }}">
<button class="win98-button" type="submit" {{ if .IsCurrent }}disabled{{ end }}>{{ if .Disabled }}Enable{{ else }}Disable{{ end }}</button>
</form>
</td>
</tr>
{{ else }}
<tr><td colspan="6">No users found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>