Compare commits
4 Commits
feat/secur
...
old-admin
| Author | SHA1 | Date | |
|---|---|---|---|
| 82d4dc815b | |||
| 89c885f637 | |||
| e103829870 | |||
| 2714907ff4 |
@@ -1,46 +0,0 @@
|
||||
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 \
|
||||
--build-arg APP_VERSION=${{ gitea.ref_name }} \
|
||||
-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
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,8 +1,6 @@
|
||||
# Stage 1: Build
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
ARG APP_VERSION=""
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
WORKDIR /build
|
||||
@@ -18,19 +16,12 @@ COPY static/ static/
|
||||
COPY templates/ templates/
|
||||
|
||||
# Build the binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/main.go
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM alpine:3.21
|
||||
|
||||
ARG APP_VERSION=""
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
RUN apk add \
|
||||
--no-cache \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
wget
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -S warpbox && adduser -S warpbox -G warpbox
|
||||
@@ -59,8 +50,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_GB=2 \
|
||||
WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \
|
||||
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \
|
||||
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \
|
||||
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
|
||||
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
|
||||
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
|
||||
@@ -69,9 +60,6 @@ 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"]
|
||||
|
||||
36
README.md
36
README.md
@@ -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. Storage path settings remain
|
||||
environment controlled.
|
||||
safe admin settings overrides from BadgerDB. Hard storage and global limit
|
||||
settings remain environment controlled.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| --- | ---: | --- |
|
||||
@@ -108,16 +108,16 @@ 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_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_GLOBAL_MAX_FILE_SIZE_BYTES` | `0` | Hard per-file cap; `0` means unlimited. |
|
||||
| `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` | `0` | Hard per-box cap; `0` means unlimited. |
|
||||
| `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` | `0` | Default user file cap. |
|
||||
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` | `0` | Default user box cap. |
|
||||
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
|
||||
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. |
|
||||
| `WARPBOX_THUMBNAIL_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
|
||||
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
|
||||
|
||||
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.
|
||||
Size limits also accept `_MB` variants for the same settings.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -189,25 +189,3 @@ 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
|
||||
```
|
||||
|
||||
|
||||
|
||||
114
TO-DO.md
114
TO-DO.md
@@ -1,114 +0,0 @@
|
||||
# WarpBox Security TO-DO
|
||||
|
||||
## 1) High Priority (Do Next)
|
||||
|
||||
- [ ] Persist IP bans across restarts
|
||||
- Current: bans stored in-memory (`lib/security/guard.go`)
|
||||
- Target: durable store in `DBDir` (similar style to `activity`/`alerts`)
|
||||
- Include: startup load, expiry cleanup, atomic writes, corruption-safe fallback
|
||||
|
||||
- [ ] Add trusted proxy CIDR config
|
||||
- Current: forwarded headers trusted only when remote hop is private/local (`lib/server/ip.go`)
|
||||
- Risk: heuristic-only trust model
|
||||
- Target:
|
||||
- `WARPBOX_TRUSTED_PROXY_CIDRS` setting
|
||||
- trust `X-Forwarded-For` only when `RemoteAddr` in trusted CIDR
|
||||
- fallback to direct remote IP otherwise
|
||||
|
||||
- [ ] Add CIDR/range support for whitelists
|
||||
- Current: exact IP match only (`WARPBOX_SECURITY_IP_WHITELIST`, `WARPBOX_SECURITY_ADMIN_IP_WHITELIST`)
|
||||
- Target: support exact IP + CIDR entries
|
||||
- Include strict parser + validation errors in settings save
|
||||
|
||||
- [ ] Add unban / ban edit API audit trail hardening
|
||||
- Ensure all manual ban/unban/ban-until actions always write:
|
||||
- activity event
|
||||
- alert (or policy-based selective alerting)
|
||||
- Add tests for these paths
|
||||
|
||||
## 2) Medium Priority
|
||||
|
||||
- [ ] GeoIP integration for security detail pane
|
||||
- Current: placeholder fields in `/admin/security`
|
||||
- Target: wire geoipfast provider for country/region/ASN fields
|
||||
- Add caching + timeout/failure-safe behavior
|
||||
|
||||
- [ ] Expand malicious path detection rules
|
||||
- Current: simple substring checks in `handleNoRoute`
|
||||
- Target:
|
||||
- rule list/pattern config
|
||||
- normalize URL + decode checks
|
||||
- classify severity by signature group
|
||||
|
||||
- [ ] Add global abuse score per IP
|
||||
- Combine signals:
|
||||
- failed admin auth
|
||||
- malicious path scans
|
||||
- upload abuse
|
||||
- Use score to escalate ban duration automatically
|
||||
|
||||
- [ ] Ban duration policy ladder
|
||||
- Current: fixed `WARPBOX_SECURITY_BAN_SECONDS`
|
||||
- Target:
|
||||
- progressive durations (e.g., 30m, 2h, 24h)
|
||||
- reset after quiet period
|
||||
|
||||
- [ ] Add security settings validation UX
|
||||
- Ensure invalid values (negative, malformed lists, invalid CIDR) rejected with clear UI errors
|
||||
- Add server tests for malformed security override payloads
|
||||
|
||||
## 3) Admin UX Follow-Ups
|
||||
|
||||
- [ ] Add dedicated “Active Bans” page-level controls
|
||||
- bulk unban
|
||||
- filter/sort by expiry and IP
|
||||
- copy IP and quick search in activity/alerts
|
||||
|
||||
- [ ] Add “why banned” detail
|
||||
- link ban entry to latest triggering events and alerts
|
||||
- show counts in active windows (login/scan/upload)
|
||||
|
||||
- [ ] Add optional confirmation modal for destructive security actions
|
||||
- unban all / bulk unban / long custom bans
|
||||
|
||||
## 4) Testing & QA
|
||||
|
||||
- [ ] Add unit tests for `lib/security/guard.go`
|
||||
- `Ban`, `BanUntil`, `Unban`, `BanList` expiry pruning
|
||||
- login/scan threshold behavior
|
||||
- upload rate limiting behavior
|
||||
|
||||
- [ ] Add tests for real-IP resolution edge cases (`lib/server/ip.go`)
|
||||
- direct client
|
||||
- trusted proxy chain
|
||||
- spoofed forwarding headers from untrusted remote
|
||||
|
||||
- [ ] Add integration tests for security endpoints
|
||||
- `/admin/security/actions` ban/ban_until/unban
|
||||
- `/admin/alerts/actions`
|
||||
- admin login brute-force auto-ban flow
|
||||
|
||||
- [ ] Add concurrency/race test pass in CI
|
||||
- run `go test ./... -race` in workflow (where Go toolchain available)
|
||||
|
||||
## 5) Operational / Deployment
|
||||
|
||||
- [ ] Document reverse-proxy setup requirements
|
||||
- Caddy / ingress config examples for forwarding headers
|
||||
- guidance for trusted proxy CIDRs
|
||||
|
||||
- [ ] Add security runbook
|
||||
- how to investigate alerts
|
||||
- how to ban/unban safely
|
||||
- how to tune thresholds for low/high traffic environments
|
||||
|
||||
- [ ] Add metrics hooks (future)
|
||||
- counts: blocked requests, bans issued, unbans, alert volume
|
||||
- expose to Prometheus-compatible endpoint later
|
||||
|
||||
## 6) Nice-to-Have (Later)
|
||||
|
||||
- [ ] Optional external enforcement bridge (fail2ban-compatible log format)
|
||||
- [ ] Webhook notifications for high-severity security alerts
|
||||
- [ ] Per-account/API-key limits once account system matures
|
||||
|
||||
@@ -175,6 +175,22 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# For podman please use :Z
|
||||
# - ./data:/app/data:Z
|
||||
- ./data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
10
docs/tech.md
10
docs/tech.md
@@ -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_GB`
|
||||
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB`
|
||||
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB`
|
||||
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB`
|
||||
- `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`
|
||||
|
||||
Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED`
|
||||
Size limit settings accept `_MB` or `_BYTES` env names. `WARPBOX_ADMIN_ENABLED`
|
||||
accepts `auto`, `true`, or `false`.
|
||||
|
||||
The HTTP listen address is configured through the CLI flag:
|
||||
|
||||
18
go.mod
18
go.mod
@@ -3,43 +3,51 @@ 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/go-cmp v0.7.0 // indirect
|
||||
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/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
33
go.sum
@@ -2,15 +2,24 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.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=
|
||||
@@ -19,6 +28,11 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -29,6 +43,8 @@ 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=
|
||||
@@ -36,15 +52,14 @@ 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=
|
||||
@@ -58,10 +73,8 @@ 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=
|
||||
@@ -86,6 +99,14 @@ 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=
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Actor string `json:"actor"`
|
||||
IP string `json:"ip"`
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func (s *Store) Append(event Event, retentionSeconds int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
events, err := s.readLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if event.CreatedAt.IsZero() {
|
||||
event.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if event.ID == "" {
|
||||
event.ID = event.CreatedAt.Format("20060102T150405.000000000")
|
||||
}
|
||||
|
||||
events = append(events, event)
|
||||
events = pruneByRetention(events, retentionSeconds)
|
||||
return s.writeLocked(events)
|
||||
}
|
||||
|
||||
func (s *Store) List(limit int, retentionSeconds int64) ([]Event, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
events, err := s.readLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = pruneByRetention(events, retentionSeconds)
|
||||
if err := s.writeLocked(events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
return events[i].CreatedAt.After(events[j].CreatedAt)
|
||||
})
|
||||
if limit > 0 && len(events) > limit {
|
||||
return events[:limit], nil
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func pruneByRetention(events []Event, retentionSeconds int64) []Event {
|
||||
if retentionSeconds <= 0 {
|
||||
return events
|
||||
}
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(retentionSeconds) * time.Second)
|
||||
out := make([]Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event.CreatedAt.IsZero() || event.CreatedAt.After(cutoff) {
|
||||
out = append(out, event)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) readLocked() ([]Event, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Event{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return []Event{}, nil
|
||||
}
|
||||
var events []Event
|
||||
if err := json.Unmarshal(data, &events); err != nil {
|
||||
return []Event{}, nil
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (s *Store) writeLocked(events []Event) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(events, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, 0644)
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOpen Status = "open"
|
||||
StatusAcked Status = "acked"
|
||||
StatusClosed Status = "closed"
|
||||
)
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
Status Status `json:"status"`
|
||||
Group string `json:"group"`
|
||||
Code string `json:"code"`
|
||||
Trace string `json:"trace"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(path string) *Store {
|
||||
return &Store{path: path}
|
||||
}
|
||||
|
||||
func (s *Store) Add(alert Alert) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
alertsList, err := s.readLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if alert.ID == "" {
|
||||
alert.ID = strconv.FormatInt(now.UnixNano(), 10)
|
||||
}
|
||||
if alert.Status == "" {
|
||||
alert.Status = StatusOpen
|
||||
}
|
||||
if alert.CreatedAt.IsZero() {
|
||||
alert.CreatedAt = now
|
||||
}
|
||||
alert.UpdatedAt = now
|
||||
alertsList = append(alertsList, alert)
|
||||
return s.writeLocked(alertsList)
|
||||
}
|
||||
|
||||
func (s *Store) List(limit int) ([]Alert, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
alertsList, err := s.readLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(alertsList, func(i, j int) bool {
|
||||
return alertsList[i].CreatedAt.After(alertsList[j].CreatedAt)
|
||||
})
|
||||
if limit > 0 && len(alertsList) > limit {
|
||||
return alertsList[:limit], nil
|
||||
}
|
||||
return alertsList, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetStatus(ids []string, status Status) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
alertsList, err := s.readLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
target[id] = true
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for i := range alertsList {
|
||||
if target[alertsList[i].ID] {
|
||||
alertsList[i].Status = status
|
||||
alertsList[i].UpdatedAt = now
|
||||
}
|
||||
}
|
||||
return s.writeLocked(alertsList)
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ids []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
alertsList, err := s.readLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
target[id] = true
|
||||
}
|
||||
kept := make([]Alert, 0, len(alertsList))
|
||||
for _, alert := range alertsList {
|
||||
if !target[alert.ID] {
|
||||
kept = append(kept, alert)
|
||||
}
|
||||
}
|
||||
return s.writeLocked(kept)
|
||||
}
|
||||
|
||||
func (s *Store) readLocked() ([]Alert, error) {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Alert{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return []Alert{}, nil
|
||||
}
|
||||
var alertsList []Alert
|
||||
if err := json.Unmarshal(data, &alertsList); err != nil {
|
||||
return []Alert{}, nil
|
||||
}
|
||||
return alertsList, nil
|
||||
}
|
||||
|
||||
func (s *Store) writeLocked(alertsList []Alert) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(alertsList, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, data, 0644)
|
||||
}
|
||||
@@ -153,43 +153,6 @@ 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()
|
||||
|
||||
@@ -204,57 +204,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.AdminPassword != "" {
|
||||
t.Fatal("expected default admin password to be empty")
|
||||
}
|
||||
if !cfg.BoxOwnerEditEnabled || !cfg.BoxOwnerRefreshEnabled || !cfg.BoxOwnerPasswordEditEnabled {
|
||||
t.Fatal("expected box owner policy defaults to be enabled")
|
||||
}
|
||||
if cfg.BoxOwnerMaxRefreshCount != 3 || cfg.BoxOwnerMaxRefreshAmountSeconds != 86400 || cfg.BoxOwnerMaxTotalExpirySeconds != 604800 {
|
||||
t.Fatalf("unexpected box owner policy defaults: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentOverrides(t *testing.T) {
|
||||
@@ -35,10 +41,12 @@ 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_GB", "0.5")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
|
||||
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
||||
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
||||
t.Setenv("WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", "5")
|
||||
t.Setenv("WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", "false")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
@@ -51,7 +59,7 @@ func TestEnvironmentOverrides(t *testing.T) {
|
||||
if cfg.GuestUploadsEnabled || cfg.APIEnabled {
|
||||
t.Fatal("expected boolean environment overrides to be applied")
|
||||
}
|
||||
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||
if cfg.GlobalMaxFileSizeBytes != 100 {
|
||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||
}
|
||||
if cfg.BoxPollIntervalMS != 2000 {
|
||||
@@ -63,6 +71,9 @@ func TestEnvironmentOverrides(t *testing.T) {
|
||||
if !cfg.OneTimeDownloadRetryOnFailure {
|
||||
t.Fatal("expected one-time retry-on-failure env override to be applied")
|
||||
}
|
||||
if cfg.BoxOwnerMaxRefreshCount != 5 || cfg.BoxOwnerPasswordEditEnabled {
|
||||
t.Fatal("expected box owner policy env overrides to be applied")
|
||||
}
|
||||
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
||||
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
||||
}
|
||||
@@ -70,25 +81,25 @@ func TestEnvironmentOverrides(t *testing.T) {
|
||||
|
||||
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "4")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "4096")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
|
||||
if cfg.GlobalMaxFileSizeBytes != 2048*1024*1024 {
|
||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||
}
|
||||
if cfg.GlobalMaxBoxSizeBytes != 4*1024*1024*1024 {
|
||||
if cfg.GlobalMaxBoxSizeBytes != 4096*1024*1024 {
|
||||
t.Fatalf("unexpected global max box size: %d", cfg.GlobalMaxBoxSizeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T) {
|
||||
func TestByteSizeEnvironmentOverridesTakePrecedence(t *testing.T) {
|
||||
clearConfigEnv(t)
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_GB", "2")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "2048")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||
|
||||
cfg, err := Load()
|
||||
@@ -96,7 +107,7 @@ func TestGBEnvironmentOverridesTakePrecedenceOverLegacySizeEnvNames(t *testing.T
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.GlobalMaxFileSizeBytes != 2*1024*1024*1024 {
|
||||
if cfg.GlobalMaxFileSizeBytes != 100 {
|
||||
t.Fatalf("unexpected global max file size: %d", cfg.GlobalMaxFileSizeBytes)
|
||||
}
|
||||
}
|
||||
@@ -145,14 +156,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, "0.5"); err != nil {
|
||||
t.Fatalf("expected global max file size override to succeed, got %v", err)
|
||||
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
|
||||
t.Fatal("expected hard limit override to fail")
|
||||
}
|
||||
if cfg.GlobalMaxFileSizeBytes != 512*1024*1024 {
|
||||
t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes)
|
||||
if err := cfg.ApplyOverride(SettingBoxOwnerMaxRefreshCount, "2"); err != nil {
|
||||
t.Fatalf("expected box owner policy override to pass: %v", err)
|
||||
}
|
||||
if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil {
|
||||
t.Fatal("expected data_dir override to remain locked")
|
||||
if cfg.BoxOwnerMaxRefreshCount != 2 {
|
||||
t.Fatalf("expected box owner policy override to apply, got %d", cfg.BoxOwnerMaxRefreshCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,22 +186,24 @@ 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",
|
||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||
"WARPBOX_BOX_OWNER_EDIT_ENABLED",
|
||||
"WARPBOX_BOX_OWNER_REFRESH_ENABLED",
|
||||
"WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT",
|
||||
"WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS",
|
||||
"WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS",
|
||||
"WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
|
||||
@@ -12,25 +12,20 @@ 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_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: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||
{Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0},
|
||||
{Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
||||
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||
{Key: SettingSecurityIPWhitelist, EnvName: "WARPBOX_SECURITY_IP_WHITELIST", Label: "Security IP whitelist", Type: SettingTypeText, Editable: true},
|
||||
{Key: SettingSecurityAdminIPWhitelist, EnvName: "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", Label: "Security admin IP whitelist", Type: SettingTypeText, Editable: true},
|
||||
{Key: SettingSecurityLoginWindowSecs, EnvName: "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", Label: "Login attempt window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
|
||||
{Key: SettingSecurityLoginMaxAttempts, EnvName: "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", Label: "Login max attempts per window", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
|
||||
{Key: SettingSecurityScanWindowSecs, EnvName: "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", Label: "Malicious path window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
|
||||
{Key: SettingSecurityScanMaxAttempts, EnvName: "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", Label: "Malicious path max attempts", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingSecurityUploadWindowSecs, EnvName: "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", Label: "Upload limit window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
|
||||
{Key: SettingSecurityUploadMaxRequests, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", Label: "Upload max requests per window", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||
{Key: SettingSecurityUploadMaxGB, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_GB", Label: "Upload max total GB per window", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
|
||||
{Key: SettingBoxOwnerEditEnabled, EnvName: "WARPBOX_BOX_OWNER_EDIT_ENABLED", Label: "Box owner edit enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingBoxOwnerRefreshEnabled, EnvName: "WARPBOX_BOX_OWNER_REFRESH_ENABLED", Label: "Box owner refresh enabled", Type: SettingTypeBool, Editable: true},
|
||||
{Key: SettingBoxOwnerMaxRefreshCount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", Label: "Box owner max refresh count", Type: SettingTypeInt, Editable: true, Minimum: 0},
|
||||
{Key: SettingBoxOwnerMaxRefreshAmount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", Label: "Box owner max refresh amount seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingBoxOwnerMaxTotalExpiry, EnvName: "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", Label: "Box owner max total expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
|
||||
{Key: SettingBoxOwnerPasswordEdit, EnvName: "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", Label: "Box owner password edit enabled", Type: SettingTypeBool, Editable: true},
|
||||
}
|
||||
|
||||
func (cfg *Config) SettingRows() []SettingRow {
|
||||
@@ -49,6 +44,10 @@ func (cfg *Config) Source(key string) Source {
|
||||
return cfg.sourceFor(key)
|
||||
}
|
||||
|
||||
func (cfg *Config) SettingValue(key string) string {
|
||||
return cfg.values[key]
|
||||
}
|
||||
|
||||
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
|
||||
switch cfg.AdminEnabled {
|
||||
case AdminEnabledFalse:
|
||||
@@ -61,7 +60,6 @@ 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
|
||||
@@ -70,35 +68,6 @@ 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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -10,34 +11,30 @@ import (
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
DataDir: "./data",
|
||||
AdminUsername: "admin",
|
||||
AdminEnabled: AdminEnabledAuto,
|
||||
AllowAdminSettingsOverride: true,
|
||||
GuestUploadsEnabled: true,
|
||||
APIEnabled: true,
|
||||
ZipDownloadsEnabled: true,
|
||||
OneTimeDownloadsEnabled: true,
|
||||
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
||||
OneTimeDownloadRetryOnFailure: false,
|
||||
DefaultGuestExpirySeconds: 10,
|
||||
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||
SessionTTLSeconds: 24 * 60 * 60,
|
||||
BoxPollIntervalMS: 5000,
|
||||
ThumbnailBatchSize: 10,
|
||||
ThumbnailIntervalSeconds: 30,
|
||||
ActivityRetentionSeconds: 7 * 24 * 60 * 60,
|
||||
SecurityLoginWindowSeconds: 10 * 60,
|
||||
SecurityLoginMaxAttempts: 8,
|
||||
SecurityBanSeconds: 30 * 60,
|
||||
SecurityScanWindowSeconds: 5 * 60,
|
||||
SecurityScanMaxAttempts: 12,
|
||||
SecurityUploadWindowSeconds: 60,
|
||||
SecurityUploadMaxRequests: 20,
|
||||
SecurityUploadMaxBytes: 10 * 1024 * 1024 * 1024,
|
||||
sources: make(map[string]Source),
|
||||
values: make(map[string]string),
|
||||
defaults: make(map[string]string),
|
||||
DataDir: "./data",
|
||||
AdminUsername: "admin",
|
||||
AdminEnabled: AdminEnabledAuto,
|
||||
AllowAdminSettingsOverride: true,
|
||||
GuestUploadsEnabled: true,
|
||||
APIEnabled: true,
|
||||
ZipDownloadsEnabled: true,
|
||||
OneTimeDownloadsEnabled: true,
|
||||
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
|
||||
OneTimeDownloadRetryOnFailure: false,
|
||||
DefaultGuestExpirySeconds: 10,
|
||||
MaxGuestExpirySeconds: 48 * 60 * 60,
|
||||
SessionTTLSeconds: 24 * 60 * 60,
|
||||
BoxPollIntervalMS: 5000,
|
||||
ThumbnailBatchSize: 10,
|
||||
ThumbnailIntervalSeconds: 30,
|
||||
BoxOwnerEditEnabled: true,
|
||||
BoxOwnerRefreshEnabled: true,
|
||||
BoxOwnerMaxRefreshCount: 3,
|
||||
BoxOwnerMaxRefreshAmountSeconds: 24 * 60 * 60,
|
||||
BoxOwnerMaxTotalExpirySeconds: 7 * 24 * 60 * 60,
|
||||
BoxOwnerPasswordEditEnabled: true,
|
||||
sources: make(map[string]Source),
|
||||
values: make(map[string]string),
|
||||
}
|
||||
|
||||
// Config precedence: defaults -> env -> overrides.
|
||||
@@ -56,12 +53,6 @@ func Load() (*Config, error) {
|
||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.applyStringEnv(SettingSecurityIPWhitelist, "WARPBOX_SECURITY_IP_WHITELIST", &cfg.SecurityIPWhitelist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||
@@ -88,6 +79,9 @@ func Load() (*Config, error) {
|
||||
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
||||
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||
{SettingBoxOwnerEditEnabled, "WARPBOX_BOX_OWNER_EDIT_ENABLED", &cfg.BoxOwnerEditEnabled},
|
||||
{SettingBoxOwnerRefreshEnabled, "WARPBOX_BOX_OWNER_REFRESH_ENABLED", &cfg.BoxOwnerRefreshEnabled},
|
||||
{SettingBoxOwnerPasswordEdit, "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", &cfg.BoxOwnerPasswordEditEnabled},
|
||||
}
|
||||
for _, item := range envBools {
|
||||
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
||||
@@ -105,11 +99,8 @@ func Load() (*Config, error) {
|
||||
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
||||
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
||||
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
||||
{SettingActivityRetentionSeconds, "WARPBOX_ACTIVITY_RETENTION_SECONDS", 60, &cfg.ActivityRetentionSeconds},
|
||||
{SettingSecurityLoginWindowSecs, "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", 10, &cfg.SecurityLoginWindowSeconds},
|
||||
{SettingSecurityBanSeconds, "WARPBOX_SECURITY_BAN_SECONDS", 10, &cfg.SecurityBanSeconds},
|
||||
{SettingSecurityScanWindowSecs, "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", 10, &cfg.SecurityScanWindowSeconds},
|
||||
{SettingSecurityUploadWindowSecs, "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", 10, &cfg.SecurityUploadWindowSeconds},
|
||||
{SettingBoxOwnerMaxRefreshAmount, "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", 0, &cfg.BoxOwnerMaxRefreshAmountSeconds},
|
||||
{SettingBoxOwnerMaxTotalExpiry, "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", 0, &cfg.BoxOwnerMaxTotalExpirySeconds},
|
||||
}
|
||||
for _, item := range envInt64s {
|
||||
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
||||
@@ -118,19 +109,17 @@ func Load() (*Config, error) {
|
||||
}
|
||||
sizeEnvVars := []struct {
|
||||
key string
|
||||
gbName string
|
||||
mbName string
|
||||
bytesName string
|
||||
target *int64
|
||||
}{
|
||||
{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},
|
||||
{SettingSecurityUploadMaxGB, "WARPBOX_SECURITY_UPLOAD_MAX_GB", "WARPBOX_SECURITY_UPLOAD_MAX_MB", "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", &cfg.SecurityUploadMaxBytes},
|
||||
{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},
|
||||
}
|
||||
for _, item := range sizeEnvVars {
|
||||
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
|
||||
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -144,9 +133,7 @@ func Load() (*Config, error) {
|
||||
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
||||
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
||||
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
||||
{SettingSecurityLoginMaxAttempts, "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", 1, &cfg.SecurityLoginMaxAttempts},
|
||||
{SettingSecurityScanMaxAttempts, "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", 1, &cfg.SecurityScanMaxAttempts},
|
||||
{SettingSecurityUploadMaxRequests, "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", 1, &cfg.SecurityUploadMaxRequests},
|
||||
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
|
||||
}
|
||||
for _, item := range envInts {
|
||||
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
||||
@@ -177,43 +164,31 @@ func (cfg *Config) EnsureDirectories() error {
|
||||
return nil
|
||||
}
|
||||
func (cfg *Config) captureDefaults() {
|
||||
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))
|
||||
cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist)
|
||||
cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist)
|
||||
cfg.captureDefaultValue(SettingSecurityLoginWindowSecs, strconv.FormatInt(cfg.SecurityLoginWindowSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingSecurityLoginMaxAttempts, strconv.Itoa(cfg.SecurityLoginMaxAttempts))
|
||||
cfg.captureDefaultValue(SettingSecurityBanSeconds, strconv.FormatInt(cfg.SecurityBanSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingSecurityScanWindowSecs, strconv.FormatInt(cfg.SecurityScanWindowSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingSecurityScanMaxAttempts, strconv.Itoa(cfg.SecurityScanMaxAttempts))
|
||||
cfg.captureDefaultValue(SettingSecurityUploadWindowSecs, strconv.FormatInt(cfg.SecurityUploadWindowSeconds, 10))
|
||||
cfg.captureDefaultValue(SettingSecurityUploadMaxRequests, strconv.Itoa(cfg.SecurityUploadMaxRequests))
|
||||
cfg.captureDefaultValue(SettingSecurityUploadMaxGB, formatGigabytesFromBytes(cfg.SecurityUploadMaxBytes))
|
||||
}
|
||||
|
||||
func (cfg *Config) captureDefaultValue(key string, value string) {
|
||||
cfg.setValue(key, value, SourceDefault)
|
||||
if cfg.defaults != nil {
|
||||
cfg.defaults[key] = value
|
||||
}
|
||||
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.setValue(SettingBoxOwnerEditEnabled, formatBool(cfg.BoxOwnerEditEnabled), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerRefreshEnabled, formatBool(cfg.BoxOwnerRefreshEnabled), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerMaxRefreshCount, strconv.Itoa(cfg.BoxOwnerMaxRefreshCount), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerMaxRefreshAmount, strconv.FormatInt(cfg.BoxOwnerMaxRefreshAmountSeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerMaxTotalExpiry, strconv.FormatInt(cfg.BoxOwnerMaxTotalExpirySeconds, 10), SourceDefault)
|
||||
cfg.setValue(SettingBoxOwnerPasswordEdit, formatBool(cfg.BoxOwnerPasswordEditEnabled), SourceDefault)
|
||||
}
|
||||
|
||||
func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
|
||||
@@ -260,23 +235,14 @@ func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error {
|
||||
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, formatGigabytesFromBytes(parsed), SourceEnv)
|
||||
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -288,9 +254,12 @@ func (cfg *Config) applySizeEnv(key string, gbName string, mbName string, bytesN
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", mbName, err)
|
||||
}
|
||||
parsedBytes := parsedMB * 1000 * 1000
|
||||
if parsedMB > math.MaxInt64/(1024*1024) {
|
||||
return fmt.Errorf("%s: is too large", mbName)
|
||||
}
|
||||
parsedBytes := parsedMB * 1024 * 1024
|
||||
*target = parsedBytes
|
||||
cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), SourceEnv)
|
||||
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,36 +27,30 @@ const (
|
||||
SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
|
||||
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
|
||||
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
|
||||
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"
|
||||
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes"
|
||||
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes"
|
||||
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes"
|
||||
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes"
|
||||
SettingSessionTTLSeconds = "session_ttl_seconds"
|
||||
SettingBoxPollIntervalMS = "box_poll_interval_ms"
|
||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||
SettingDataDir = "data_dir"
|
||||
SettingActivityRetentionSeconds = "activity_retention_seconds"
|
||||
SettingSecurityIPWhitelist = "security_ip_whitelist"
|
||||
SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist"
|
||||
SettingSecurityLoginWindowSecs = "security_login_window_seconds"
|
||||
SettingSecurityLoginMaxAttempts = "security_login_max_attempts"
|
||||
SettingSecurityBanSeconds = "security_ban_seconds"
|
||||
SettingSecurityScanWindowSecs = "security_scan_window_seconds"
|
||||
SettingSecurityScanMaxAttempts = "security_scan_max_attempts"
|
||||
SettingSecurityUploadWindowSecs = "security_upload_window_seconds"
|
||||
SettingSecurityUploadMaxRequests = "security_upload_max_requests"
|
||||
SettingSecurityUploadMaxGB = "security_upload_max_gb"
|
||||
SettingBoxOwnerEditEnabled = "box_owner_edit_enabled"
|
||||
SettingBoxOwnerRefreshEnabled = "box_owner_refresh_enabled"
|
||||
SettingBoxOwnerMaxRefreshCount = "box_owner_max_refresh_count"
|
||||
SettingBoxOwnerMaxRefreshAmount = "box_owner_max_refresh_amount_seconds"
|
||||
SettingBoxOwnerMaxTotalExpiry = "box_owner_max_total_expiry_seconds"
|
||||
SettingBoxOwnerPasswordEdit = "box_owner_password_edit_enabled"
|
||||
)
|
||||
|
||||
type SettingType string
|
||||
|
||||
const (
|
||||
SettingTypeBool SettingType = "bool"
|
||||
SettingTypeInt64 SettingType = "int64"
|
||||
SettingTypeInt SettingType = "int"
|
||||
SettingTypeText SettingType = "text"
|
||||
SettingTypeSizeGB SettingType = "size_gb"
|
||||
SettingTypeBool SettingType = "bool"
|
||||
SettingTypeInt64 SettingType = "int64"
|
||||
SettingTypeInt SettingType = "int"
|
||||
SettingTypeText SettingType = "text"
|
||||
)
|
||||
|
||||
type SettingDefinition struct {
|
||||
@@ -96,29 +90,23 @@ type Config struct {
|
||||
RenewOnAccessEnabled bool
|
||||
RenewOnDownloadEnabled bool
|
||||
|
||||
DefaultGuestExpirySeconds int64
|
||||
MaxGuestExpirySeconds int64
|
||||
GlobalMaxFileSizeBytes int64
|
||||
GlobalMaxBoxSizeBytes int64
|
||||
DefaultUserMaxFileSizeBytes int64
|
||||
DefaultUserMaxBoxSizeBytes int64
|
||||
SessionTTLSeconds int64
|
||||
BoxPollIntervalMS int
|
||||
ThumbnailBatchSize int
|
||||
ThumbnailIntervalSeconds int
|
||||
ActivityRetentionSeconds int64
|
||||
SecurityIPWhitelist string
|
||||
SecurityAdminIPWhitelist string
|
||||
SecurityLoginWindowSeconds int64
|
||||
SecurityLoginMaxAttempts int
|
||||
SecurityBanSeconds int64
|
||||
SecurityScanWindowSeconds int64
|
||||
SecurityScanMaxAttempts int
|
||||
SecurityUploadWindowSeconds int64
|
||||
SecurityUploadMaxRequests int
|
||||
SecurityUploadMaxBytes int64
|
||||
DefaultGuestExpirySeconds int64
|
||||
MaxGuestExpirySeconds int64
|
||||
GlobalMaxFileSizeBytes int64
|
||||
GlobalMaxBoxSizeBytes int64
|
||||
DefaultUserMaxFileSizeBytes int64
|
||||
DefaultUserMaxBoxSizeBytes int64
|
||||
SessionTTLSeconds int64
|
||||
BoxPollIntervalMS int
|
||||
ThumbnailBatchSize int
|
||||
ThumbnailIntervalSeconds int
|
||||
BoxOwnerEditEnabled bool
|
||||
BoxOwnerRefreshEnabled bool
|
||||
BoxOwnerMaxRefreshCount int
|
||||
BoxOwnerMaxRefreshAmountSeconds int64
|
||||
BoxOwnerMaxTotalExpirySeconds int64
|
||||
BoxOwnerPasswordEditEnabled bool
|
||||
|
||||
sources map[string]Source
|
||||
values map[string]string
|
||||
defaults map[string]string
|
||||
sources map[string]Source
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -39,20 +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 {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
}
|
||||
cfg.assignInt(key, int(parsed64), SourceDB)
|
||||
case SettingTypeText:
|
||||
cfg.assignText(key, value, SourceDB)
|
||||
default:
|
||||
return fmt.Errorf("setting %q is not runtime editable", key)
|
||||
}
|
||||
@@ -72,6 +64,12 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||
cfg.RenewOnAccessEnabled = value
|
||||
case SettingRenewOnDownloadEnabled:
|
||||
cfg.RenewOnDownloadEnabled = value
|
||||
case SettingBoxOwnerEditEnabled:
|
||||
cfg.BoxOwnerEditEnabled = value
|
||||
case SettingBoxOwnerRefreshEnabled:
|
||||
cfg.BoxOwnerRefreshEnabled = value
|
||||
case SettingBoxOwnerPasswordEdit:
|
||||
cfg.BoxOwnerPasswordEditEnabled = value
|
||||
}
|
||||
cfg.setValue(key, formatBool(value), source)
|
||||
}
|
||||
@@ -84,32 +82,16 @@ 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:
|
||||
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||
case SettingSessionTTLSeconds:
|
||||
cfg.SessionTTLSeconds = value
|
||||
case SettingActivityRetentionSeconds:
|
||||
cfg.ActivityRetentionSeconds = value
|
||||
case SettingSecurityLoginWindowSecs:
|
||||
cfg.SecurityLoginWindowSeconds = value
|
||||
case SettingSecurityBanSeconds:
|
||||
cfg.SecurityBanSeconds = value
|
||||
case SettingSecurityScanWindowSecs:
|
||||
cfg.SecurityScanWindowSeconds = value
|
||||
case SettingSecurityUploadWindowSecs:
|
||||
cfg.SecurityUploadWindowSeconds = value
|
||||
case SettingSecurityUploadMaxGB:
|
||||
cfg.SecurityUploadMaxBytes = value
|
||||
}
|
||||
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes || key == SettingSecurityUploadMaxGB {
|
||||
cfg.setValue(key, formatGigabytesFromBytes(value), source)
|
||||
return
|
||||
case SettingBoxOwnerMaxRefreshAmount:
|
||||
cfg.BoxOwnerMaxRefreshAmountSeconds = value
|
||||
case SettingBoxOwnerMaxTotalExpiry:
|
||||
cfg.BoxOwnerMaxTotalExpirySeconds = value
|
||||
}
|
||||
cfg.setValue(key, strconv.FormatInt(value, 10), source)
|
||||
}
|
||||
@@ -122,26 +104,12 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
|
||||
cfg.ThumbnailBatchSize = value
|
||||
case SettingThumbnailIntervalSeconds:
|
||||
cfg.ThumbnailIntervalSeconds = value
|
||||
case SettingSecurityLoginMaxAttempts:
|
||||
cfg.SecurityLoginMaxAttempts = value
|
||||
case SettingSecurityScanMaxAttempts:
|
||||
cfg.SecurityScanMaxAttempts = value
|
||||
case SettingSecurityUploadMaxRequests:
|
||||
cfg.SecurityUploadMaxRequests = value
|
||||
case SettingBoxOwnerMaxRefreshCount:
|
||||
cfg.BoxOwnerMaxRefreshCount = value
|
||||
}
|
||||
cfg.setValue(key, strconv.Itoa(value), source)
|
||||
}
|
||||
|
||||
func (cfg *Config) assignText(key string, value string, source Source) {
|
||||
switch key {
|
||||
case SettingSecurityIPWhitelist:
|
||||
cfg.SecurityIPWhitelist = value
|
||||
case SettingSecurityAdminIPWhitelist:
|
||||
cfg.SecurityAdminIPWhitelist = value
|
||||
}
|
||||
cfg.setValue(key, value, source)
|
||||
}
|
||||
|
||||
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||
if key == "" {
|
||||
return
|
||||
@@ -157,10 +125,3 @@ 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]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -40,46 +39,6 @@ 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"
|
||||
|
||||
247
lib/metastore/alerts.go
Normal file
247
lib/metastore/alerts.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
|
||||
"warpbox/lib/helpers"
|
||||
)
|
||||
|
||||
const (
|
||||
AlertSeverityLow = "low"
|
||||
AlertSeverityMedium = "medium"
|
||||
AlertSeverityHigh = "high"
|
||||
|
||||
AlertStatusOpen = "open"
|
||||
AlertStatusAcknowledged = "acknowledged"
|
||||
AlertStatusClosed = "closed"
|
||||
)
|
||||
|
||||
func (store *Store) CreateAlert(input AlertInput) (Alert, error) {
|
||||
alert, err := normalizeAlertInput(input)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
id, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
alert.ID = id
|
||||
alert.Status = AlertStatusOpen
|
||||
alert.CreatedAt = now
|
||||
alert.UpdatedAt = now
|
||||
|
||||
err = store.db.Update(func(txn *badger.Txn) error {
|
||||
return putJSON(txn, alertKey(alert.ID), alert)
|
||||
})
|
||||
return alert, err
|
||||
}
|
||||
|
||||
func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) {
|
||||
alerts := []Alert{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("alert/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var alert Alert
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &alert)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if alertMatchesFilters(alert, filters) {
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sortAlerts(alerts, filters.Sort)
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
func (store *Store) GetAlert(id string) (Alert, bool, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return Alert{}, false, nil
|
||||
}
|
||||
var alert Alert
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
return getJSON(txn, alertKey(id), &alert)
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return Alert{}, false, nil
|
||||
}
|
||||
return alert, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) AcknowledgeAlert(id string) error {
|
||||
return store.updateAlertStatus(id, AlertStatusAcknowledged)
|
||||
}
|
||||
|
||||
func (store *Store) CloseAlert(id string) error {
|
||||
return store.updateAlertStatus(id, AlertStatusClosed)
|
||||
}
|
||||
|
||||
func (store *Store) updateAlertStatus(id string, status string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid)
|
||||
}
|
||||
status, err := normalizeAlertStatus(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
var alert Alert
|
||||
if err := getJSON(txn, alertKey(id), &alert); err != nil {
|
||||
return err
|
||||
}
|
||||
alert.Status = status
|
||||
alert.UpdatedAt = now
|
||||
switch status {
|
||||
case AlertStatusAcknowledged:
|
||||
alert.AcknowledgedAt = &now
|
||||
case AlertStatusClosed:
|
||||
alert.ClosedAt = &now
|
||||
}
|
||||
return putJSON(txn, alertKey(id), alert)
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeAlertInput(input AlertInput) (Alert, error) {
|
||||
title := strings.TrimSpace(input.Title)
|
||||
description := strings.TrimSpace(input.Description)
|
||||
code := strings.TrimSpace(input.Code)
|
||||
trace := strings.TrimSpace(input.Trace)
|
||||
severity, err := normalizeAlertSeverity(input.Severity)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
if title == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid)
|
||||
}
|
||||
if code == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid)
|
||||
}
|
||||
if trace == "" {
|
||||
return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid)
|
||||
}
|
||||
metadata := input.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = json.RawMessage(`{}`)
|
||||
}
|
||||
var object map[string]any
|
||||
if err := json.Unmarshal(metadata, &object); err != nil {
|
||||
return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid)
|
||||
}
|
||||
normalizedMetadata, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return Alert{}, err
|
||||
}
|
||||
return Alert{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: normalizedMetadata,
|
||||
CreatedBy: strings.TrimSpace(input.CreatedBy),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAlertSeverity(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh:
|
||||
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAlertStatus(value string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed:
|
||||
return strings.ToLower(strings.TrimSpace(value)), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: invalid alert status", ErrInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func alertMatchesFilters(alert Alert, filters AlertFilters) bool {
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
if query != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " "))
|
||||
if !strings.Contains(haystack, query) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity {
|
||||
return false
|
||||
}
|
||||
if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status {
|
||||
return false
|
||||
}
|
||||
if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sortAlerts(alerts []Alert, sortKey string) {
|
||||
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||
case "oldest":
|
||||
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) })
|
||||
case "severity":
|
||||
sort.Slice(alerts, func(i int, j int) bool {
|
||||
left := alertSeverityRank(alerts[i].Severity)
|
||||
right := alertSeverityRank(alerts[j].Severity)
|
||||
if left == right {
|
||||
return alerts[i].CreatedAt.After(alerts[j].CreatedAt)
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
default:
|
||||
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) })
|
||||
}
|
||||
}
|
||||
|
||||
func alertSeverityRank(severity string) int {
|
||||
switch severity {
|
||||
case AlertSeverityHigh:
|
||||
return 3
|
||||
case AlertSeverityMedium:
|
||||
return 2
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func alertGroup(trace string) string {
|
||||
trace = strings.TrimSpace(trace)
|
||||
if trace == "" {
|
||||
return "system"
|
||||
}
|
||||
before, _, found := strings.Cut(trace, ".")
|
||||
if !found || before == "" {
|
||||
return "system"
|
||||
}
|
||||
return strings.ToLower(before)
|
||||
}
|
||||
|
||||
func alertKey(id string) []byte {
|
||||
return []byte("alert/" + strings.TrimSpace(id))
|
||||
}
|
||||
89
lib/metastore/alerts_test.go
Normal file
89
lib/metastore/alerts_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAlertCreateListFilterLifecycle(t *testing.T) {
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
alert, err := store.CreateAlert(AlertInput{
|
||||
Title: "Thumbnail failed",
|
||||
Description: "Could not generate preview.",
|
||||
Severity: AlertSeverityMedium,
|
||||
Code: "601",
|
||||
Trace: "thumbnail.generate.failed",
|
||||
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAlert returned error: %v", err)
|
||||
}
|
||||
if alert.ID == "" || alert.Status != AlertStatusOpen {
|
||||
t.Fatalf("unexpected alert: %#v", alert)
|
||||
}
|
||||
|
||||
alerts, err := store.ListAlerts(AlertFilters{Severity: AlertSeverityMedium, Status: AlertStatusOpen})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAlerts returned error: %v", err)
|
||||
}
|
||||
if len(alerts) != 1 || alerts[0].Trace != "thumbnail.generate.failed" {
|
||||
t.Fatalf("unexpected filtered alerts: %#v", alerts)
|
||||
}
|
||||
|
||||
if !json.Valid(alerts[0].Metadata) {
|
||||
t.Fatalf("expected valid metadata JSON: %s", string(alerts[0].Metadata))
|
||||
}
|
||||
var metadata map[string]string
|
||||
if err := json.Unmarshal(alerts[0].Metadata, &metadata); err != nil {
|
||||
t.Fatalf("Unmarshal metadata returned error: %v", err)
|
||||
}
|
||||
if metadata["file"] != "photo.jpg" {
|
||||
t.Fatalf("metadata did not survive round trip: %#v", metadata)
|
||||
}
|
||||
|
||||
if err := store.AcknowledgeAlert(alert.ID); err != nil {
|
||||
t.Fatalf("AcknowledgeAlert returned error: %v", err)
|
||||
}
|
||||
acknowledged, ok, err := store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if acknowledged.Status != AlertStatusAcknowledged || acknowledged.AcknowledgedAt == nil {
|
||||
t.Fatalf("expected acknowledged alert, got %#v", acknowledged)
|
||||
}
|
||||
|
||||
if err := store.CloseAlert(alert.ID); err != nil {
|
||||
t.Fatalf("CloseAlert returned error: %v", err)
|
||||
}
|
||||
closed, ok, err := store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if closed.Status != AlertStatusClosed || closed.ClosedAt == nil {
|
||||
t.Fatalf("expected closed alert, got %#v", closed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRejectsInvalidMetadata(t *testing.T) {
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.CreateAlert(AlertInput{
|
||||
Title: "Bad alert",
|
||||
Severity: AlertSeverityLow,
|
||||
Code: "999",
|
||||
Trace: "test.bad",
|
||||
Metadata: json.RawMessage(`[]`),
|
||||
}); err == nil {
|
||||
t.Fatal("expected non-object metadata to be rejected")
|
||||
}
|
||||
}
|
||||
71
lib/metastore/bootstrap.go
Normal file
71
lib/metastore/bootstrap.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
func BootstrapAdmin(cfg *config.Config, store *Store) (BootstrapResult, error) {
|
||||
adminTag, err := store.EnsureAdminTag()
|
||||
if err != nil {
|
||||
return BootstrapResult{}, err
|
||||
}
|
||||
|
||||
var adminUser *User
|
||||
user, ok, err := store.GetUserByUsername(cfg.AdminUsername)
|
||||
if err != nil {
|
||||
return BootstrapResult{}, err
|
||||
}
|
||||
if ok {
|
||||
if !hasString(user.TagIDs, adminTag.ID) {
|
||||
user.TagIDs = append(user.TagIDs, adminTag.ID)
|
||||
if err := store.UpdateUser(user); err != nil {
|
||||
return BootstrapResult{}, err
|
||||
}
|
||||
}
|
||||
adminUser = &user
|
||||
} else if strings.TrimSpace(cfg.AdminPassword) != "" {
|
||||
created, err := store.CreateUserWithPassword(cfg.AdminUsername, cfg.AdminEmail, cfg.AdminPassword, []string{adminTag.ID})
|
||||
if err != nil {
|
||||
return BootstrapResult{}, err
|
||||
}
|
||||
adminUser = &created
|
||||
}
|
||||
|
||||
hasAdminUser, err := store.HasAdminUser(adminTag.ID)
|
||||
if err != nil {
|
||||
return BootstrapResult{}, err
|
||||
}
|
||||
|
||||
return BootstrapResult{
|
||||
AdminTag: adminTag,
|
||||
AdminUser: adminUser,
|
||||
AdminLoginEnabled: cfg.AdminLoginEnabled(hasAdminUser),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (store *Store) HasAdminUser(adminTagID string) (bool, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.Disabled {
|
||||
continue
|
||||
}
|
||||
if hasString(user.TagIDs, adminTagID) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func hasString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
188
lib/metastore/boxes.go
Normal file
188
lib/metastore/boxes.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
)
|
||||
|
||||
func (store *Store) UpsertBoxRecord(record BoxRecord) error {
|
||||
record.ID = strings.TrimSpace(record.ID)
|
||||
if record.ID == "" {
|
||||
return errors.New("box id cannot be empty")
|
||||
}
|
||||
record.OwnerID = strings.TrimSpace(record.OwnerID)
|
||||
record.OwnerUsername = strings.TrimSpace(record.OwnerUsername)
|
||||
record.FileNames = uniqueStrings(record.FileNames)
|
||||
record.UpdatedAt = time.Now().UTC()
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
return putJSON(txn, boxRecordKey(record.ID), record)
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) GetBoxRecord(id string) (BoxRecord, bool, error) {
|
||||
var record BoxRecord
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
return getJSON(txn, boxRecordKey(id), &record)
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return BoxRecord{}, false, nil
|
||||
}
|
||||
return record, err == nil, err
|
||||
}
|
||||
|
||||
func (store *Store) DeleteBoxRecord(id string) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
err := txn.Delete(boxRecordKey(id))
|
||||
if errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) ListBoxRecords(filters BoxFilters, page BoxPageRequest) (BoxRecordPage, error) {
|
||||
if page.Page < 1 {
|
||||
page.Page = 1
|
||||
}
|
||||
switch page.PageSize {
|
||||
case 25, 50, 100:
|
||||
default:
|
||||
page.PageSize = 25
|
||||
}
|
||||
|
||||
rows := []BoxRecord{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("box_record/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var record BoxRecord
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &record)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if boxRecordMatches(record, filters) {
|
||||
rows = append(rows, record)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return BoxRecordPage{}, err
|
||||
}
|
||||
|
||||
sortBoxRecords(rows, filters.Sort)
|
||||
total := len(rows)
|
||||
start := (page.Page - 1) * page.PageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + page.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
totalPages := 1
|
||||
if total > 0 {
|
||||
totalPages = (total + page.PageSize - 1) / page.PageSize
|
||||
}
|
||||
return BoxRecordPage{
|
||||
Rows: rows[start:end],
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: total,
|
||||
HasPrev: page.Page > 1,
|
||||
HasNext: end < total,
|
||||
PrevPage: maxInt(page.Page-1, 1),
|
||||
NextPage: page.Page + 1,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func boxRecordMatches(record BoxRecord, filters BoxFilters) bool {
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
if query != "" {
|
||||
haystack := strings.ToLower(record.ID + " " + record.OwnerUsername + " " + strings.Join(record.FileNames, " "))
|
||||
if !strings.Contains(haystack, query) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
owner := strings.ToLower(strings.TrimSpace(filters.Owner))
|
||||
if owner != "" && owner != "all" && strings.ToLower(record.OwnerUsername) != owner && strings.ToLower(record.OwnerID) != owner {
|
||||
return false
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(filters.Status))
|
||||
if status != "" && status != "all" && boxRecordStatus(record) != status {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(filters.Flag)) {
|
||||
case "", "all":
|
||||
return true
|
||||
case "password":
|
||||
return record.PasswordProtected
|
||||
case "one-time":
|
||||
return record.OneTimeDownload
|
||||
case "zip-disabled":
|
||||
return record.DisableZip
|
||||
case "expired":
|
||||
return boxRecordExpired(record)
|
||||
case "refreshable":
|
||||
return !record.OneTimeDownload && !boxRecordExpired(record)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func sortBoxRecords(rows []BoxRecord, sortKey string) {
|
||||
switch strings.ToLower(strings.TrimSpace(sortKey)) {
|
||||
case "oldest":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.Before(rows[j].CreatedAt) })
|
||||
case "largest":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].TotalSize > rows[j].TotalSize })
|
||||
case "expires":
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].ExpiresAt.Before(rows[j].ExpiresAt) })
|
||||
case "expired":
|
||||
sort.Slice(rows, func(i int, j int) bool {
|
||||
left := boxRecordExpired(rows[i])
|
||||
right := boxRecordExpired(rows[j])
|
||||
if left == right {
|
||||
return rows[i].CreatedAt.After(rows[j].CreatedAt)
|
||||
}
|
||||
return left
|
||||
})
|
||||
default:
|
||||
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.After(rows[j].CreatedAt) })
|
||||
}
|
||||
}
|
||||
|
||||
func boxRecordStatus(record BoxRecord) string {
|
||||
if boxRecordExpired(record) {
|
||||
return "expired"
|
||||
}
|
||||
if record.ExpiresAt.IsZero() {
|
||||
return "pending"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func boxRecordExpired(record BoxRecord) bool {
|
||||
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
|
||||
}
|
||||
|
||||
func boxRecordKey(id string) []byte {
|
||||
return []byte("box_record/" + strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
func maxInt(a int, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
222
lib/metastore/metastore_test.go
Normal file
222
lib/metastore/metastore_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
func TestOpenClose(t *testing.T) {
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapAdminFromPassword(t *testing.T) {
|
||||
clearMetastoreConfigEnv(t)
|
||||
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret-pass")
|
||||
t.Setenv("WARPBOX_ADMIN_EMAIL", "admin@example.test")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
store := openTestStore(t)
|
||||
|
||||
result, err := BootstrapAdmin(cfg, store)
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapAdmin returned error: %v", err)
|
||||
}
|
||||
if !result.AdminLoginEnabled {
|
||||
t.Fatal("expected admin login to be enabled")
|
||||
}
|
||||
if !result.AdminTag.Protected {
|
||||
t.Fatal("expected admin tag to be protected")
|
||||
}
|
||||
if result.AdminUser == nil {
|
||||
t.Fatal("expected bootstrap admin user")
|
||||
}
|
||||
if !hasString(result.AdminUser.TagIDs, result.AdminTag.ID) {
|
||||
t.Fatal("expected bootstrap admin to have admin tag")
|
||||
}
|
||||
if !VerifyPassword(result.AdminUser.PasswordHash, "secret-pass") {
|
||||
t.Fatal("expected bootstrap admin password to verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapAdminDisabledWithoutPassword(t *testing.T) {
|
||||
clearMetastoreConfigEnv(t)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
store := openTestStore(t)
|
||||
|
||||
result, err := BootstrapAdmin(cfg, store)
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapAdmin returned error: %v", err)
|
||||
}
|
||||
if result.AdminLoginEnabled {
|
||||
t.Fatal("expected admin login to be disabled without password or existing admin")
|
||||
}
|
||||
if !result.AdminTag.Protected {
|
||||
t.Fatal("expected admin tag to still be created")
|
||||
}
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers returned error: %v", err)
|
||||
}
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("expected no users, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateUsersAndTags(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
|
||||
if _, err := store.CreateUserWithPassword("alex", "alex@example.test", "secret", nil); err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
if _, err := store.CreateUserWithPassword("Alex", "other@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("expected duplicate username error, got %v", err)
|
||||
}
|
||||
if _, err := store.CreateUserWithPassword("other", "alex@example.test", "secret", nil); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("expected duplicate email error, got %v", err)
|
||||
}
|
||||
|
||||
tag := Tag{Name: "staff"}
|
||||
if err := store.CreateTag(&tag); err != nil {
|
||||
t.Fatalf("CreateTag returned error: %v", err)
|
||||
}
|
||||
duplicate := Tag{Name: "Staff"}
|
||||
if err := store.CreateTag(&duplicate); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatalf("expected duplicate tag error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionResolutionAndGlobalCaps(t *testing.T) {
|
||||
clearMetastoreConfigEnv(t)
|
||||
t.Setenv("WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", "50")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", "100")
|
||||
t.Setenv("WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", "1000")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
tagFileLimit := int64(80)
|
||||
tagBoxLimit := int64(2000)
|
||||
userFileLimit := int64(60)
|
||||
user := User{MaxFileSizeBytes: &userFileLimit}
|
||||
tags := []Tag{
|
||||
{
|
||||
Permissions: TagPermissions{
|
||||
UploadAllowed: true,
|
||||
AllowedExpirySeconds: []int64{3600, 600},
|
||||
MaxFileSizeBytes: &tagFileLimit,
|
||||
MaxBoxSizeBytes: &tagBoxLimit,
|
||||
ZipDownloadAllowed: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
perms := ResolveUserPermissions(cfg, user, tags)
|
||||
if !perms.UploadAllowed || !perms.ZipDownloadAllowed {
|
||||
t.Fatal("expected tag booleans to grant permissions")
|
||||
}
|
||||
if perms.MaxFileSizeBytes != 80 {
|
||||
t.Fatalf("expected tag limit to beat user/default limit, got %d", perms.MaxFileSizeBytes)
|
||||
}
|
||||
if perms.MaxBoxSizeBytes != 1000 {
|
||||
t.Fatalf("expected global max box cap, got %d", perms.MaxBoxSizeBytes)
|
||||
}
|
||||
if len(perms.AllowedExpirySeconds) != 2 || perms.AllowedExpirySeconds[0] != 600 || perms.AllowedExpirySeconds[1] != 3600 {
|
||||
t.Fatalf("unexpected expiry durations: %#v", perms.AllowedExpirySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsStorageAndPrecedence(t *testing.T) {
|
||||
clearMetastoreConfigEnv(t)
|
||||
t.Setenv("WARPBOX_API_ENABLED", "true")
|
||||
|
||||
store := openTestStore(t)
|
||||
if err := store.SetSetting(config.SettingAPIEnabled, "false"); err != nil {
|
||||
t.Fatalf("SetSetting returned error: %v", err)
|
||||
}
|
||||
overrides, err := store.ListSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("ListSettings returned error: %v", err)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
t.Fatalf("ApplyOverrides returned error: %v", err)
|
||||
}
|
||||
if cfg.APIEnabled {
|
||||
t.Fatal("expected stored DB override to beat env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpiry(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
session, err := store.CreateSession("user-id", time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
if _, ok, err := store.GetSession(session.Token); err != nil || ok {
|
||||
t.Fatalf("expected expired session to be invalid, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
store, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = store.Close()
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
func clearMetastoreConfigEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"WARPBOX_DATA_DIR",
|
||||
"WARPBOX_ADMIN_PASSWORD",
|
||||
"WARPBOX_ADMIN_USERNAME",
|
||||
"WARPBOX_ADMIN_EMAIL",
|
||||
"WARPBOX_ADMIN_ENABLED",
|
||||
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||
"WARPBOX_API_ENABLED",
|
||||
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||
"WARPBOX_SESSION_TTL_SECONDS",
|
||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
242
lib/metastore/models.go
Normal file
242
lib/metastore/models.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"`
|
||||
AdminNote string `json:"admin_note,omitempty"`
|
||||
MaxFileSizeBytes *int64 `json:"max_file_size_bytes,omitempty"`
|
||||
MaxBoxSizeBytes *int64 `json:"max_box_size_bytes,omitempty"`
|
||||
MaxExpirySeconds *int64 `json:"max_expiry_seconds,omitempty"`
|
||||
PermOverrides *UserPermOverrides `json:"perm_overrides,omitempty"`
|
||||
}
|
||||
|
||||
type UserPermOverrides struct {
|
||||
UploadAllowed *bool `json:"upload_allowed,omitempty"`
|
||||
ManageOwnBoxes *bool `json:"manage_own_boxes,omitempty"`
|
||||
ZipDownloadAllowed *bool `json:"zip_download_allowed,omitempty"`
|
||||
OneTimeDownloadAllowed *bool `json:"one_time_download_allowed,omitempty"`
|
||||
RenewableAllowed *bool `json:"renewable_allowed,omitempty"`
|
||||
AllowPasswordProtected *bool `json:"allow_password_protected,omitempty"`
|
||||
RenewOnAccess *bool `json:"renew_on_access,omitempty"`
|
||||
RenewOnDownload *bool `json:"renew_on_download,omitempty"`
|
||||
AllowOwnerBoxEditing *bool `json:"allow_owner_box_editing,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"`
|
||||
AdminUsersView bool `json:"admin_users_view"`
|
||||
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
|
||||
AdminUsersView bool
|
||||
AdminUsersManage bool
|
||||
AdminSettingsManage bool
|
||||
AdminBoxesView bool
|
||||
}
|
||||
|
||||
type BootstrapResult struct {
|
||||
AdminTag Tag
|
||||
AdminUser *User
|
||||
AdminLoginEnabled bool
|
||||
}
|
||||
|
||||
type Alert struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Trace string `json:"trace"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
ClosedAt *time.Time `json:"closed_at,omitempty"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
type AlertInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Severity string
|
||||
Code string
|
||||
Trace string
|
||||
Metadata json.RawMessage
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type AlertFilters struct {
|
||||
Query string
|
||||
Severity string
|
||||
Status string
|
||||
Group string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type BoxRecord struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
FileNames []string `json:"file_names,omitempty"`
|
||||
FileCount int `json:"file_count"`
|
||||
TotalSize int64 `json:"total_size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
PasswordProtected bool `json:"password_protected"`
|
||||
OneTimeDownload bool `json:"one_time_download"`
|
||||
DisableZip bool `json:"disable_zip"`
|
||||
RefreshCount int `json:"refresh_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type BoxFilters struct {
|
||||
Query string
|
||||
Owner string
|
||||
Status string
|
||||
Flag string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type BoxPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type BoxRecordPage struct {
|
||||
Rows []BoxRecord
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
TotalPages int
|
||||
}
|
||||
|
||||
type UserFilters struct {
|
||||
Query string
|
||||
Status string
|
||||
Role string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type UserPageRequest struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type UserRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Email string
|
||||
Status string
|
||||
Role string
|
||||
TagIDs []string
|
||||
Tags string
|
||||
Plan string
|
||||
PolicySummary string
|
||||
BoxCount int
|
||||
APIKeyCount int
|
||||
CreatedAt string
|
||||
LastSeen string
|
||||
Disabled bool
|
||||
IsCurrent bool
|
||||
IsInvite bool
|
||||
}
|
||||
|
||||
type UserPage struct {
|
||||
Rows []UserRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
TotalPages int
|
||||
Stats UserPageStats
|
||||
}
|
||||
|
||||
type UserPageStats struct {
|
||||
TotalUsers int
|
||||
ActiveUsers int
|
||||
PendingInvites int
|
||||
DisabledUsers int
|
||||
}
|
||||
|
||||
type CreateUserInput struct {
|
||||
Username string
|
||||
Email string
|
||||
Password string
|
||||
Mode string
|
||||
Role string
|
||||
Plan string
|
||||
AdminNote string
|
||||
SendSetup bool
|
||||
ForceChange bool
|
||||
}
|
||||
|
||||
type CreateUserResult struct {
|
||||
User User
|
||||
InviteToken string
|
||||
InviteLink string
|
||||
IsInvite bool
|
||||
PasswordSet string
|
||||
InviteNotSent bool
|
||||
}
|
||||
157
lib/metastore/permissions.go
Normal file
157
lib/metastore/permissions.go
Normal file
@@ -0,0 +1,157 @@
|
||||
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.AdminUsersView = perms.AdminUsersView || tagPerms.AdminUsersView
|
||||
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
|
||||
}
|
||||
|
||||
if o := user.PermOverrides; o != nil {
|
||||
if o.UploadAllowed != nil {
|
||||
perms.UploadAllowed = *o.UploadAllowed
|
||||
}
|
||||
if o.ZipDownloadAllowed != nil {
|
||||
perms.ZipDownloadAllowed = *o.ZipDownloadAllowed
|
||||
}
|
||||
if o.OneTimeDownloadAllowed != nil {
|
||||
perms.OneTimeDownloadAllowed = *o.OneTimeDownloadAllowed
|
||||
}
|
||||
if o.RenewableAllowed != nil {
|
||||
perms.RenewableAllowed = *o.RenewableAllowed
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
79
lib/metastore/sessions.go
Normal file
79
lib/metastore/sessions.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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))
|
||||
}
|
||||
669
lib/metastore/store.go
Normal file
669
lib/metastore/store.go
Normal file
@@ -0,0 +1,669 @@
|
||||
package metastore
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"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) ListUsersPaginated(filters UserFilters, pageReq UserPageRequest) (UserPage, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
|
||||
tags, err := store.ListTags()
|
||||
if err != nil {
|
||||
return UserPage{}, err
|
||||
}
|
||||
tagMap := make(map[string]Tag, len(tags))
|
||||
for _, tag := range tags {
|
||||
tagMap[tag.ID] = tag
|
||||
}
|
||||
|
||||
query := strings.ToLower(strings.TrimSpace(filters.Query))
|
||||
filtered := make([]User, 0, len(users))
|
||||
for _, user := range users {
|
||||
if query != "" {
|
||||
if !strings.Contains(strings.ToLower(user.Username), query) &&
|
||||
!strings.Contains(strings.ToLower(user.Email), query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch filters.Status {
|
||||
case "active":
|
||||
if user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "disabled":
|
||||
if !user.Disabled || strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
case "pending":
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if filters.Role != "" && filters.Role != "all" {
|
||||
match := false
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, filters.Role) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, user)
|
||||
}
|
||||
|
||||
switch filters.Sort {
|
||||
case "createdDesc":
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return filtered[i].CreatedAt.After(filtered[j].CreatedAt)
|
||||
})
|
||||
case "username":
|
||||
fallthrough
|
||||
default:
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
return strings.ToLower(filtered[i].Username) < strings.ToLower(filtered[j].Username)
|
||||
})
|
||||
}
|
||||
|
||||
total := len(filtered)
|
||||
pageSize := pageReq.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 12
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
totalPages := (total + pageSize - 1) / pageSize
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
page := pageReq.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
pageUsers := filtered[start:end]
|
||||
|
||||
stats := UserPageStats{TotalUsers: len(users)}
|
||||
for _, user := range users {
|
||||
if strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
stats.PendingInvites++
|
||||
} else if user.Disabled {
|
||||
stats.DisabledUsers++
|
||||
} else {
|
||||
stats.ActiveUsers++
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]UserRow, len(pageUsers))
|
||||
for i, user := range pageUsers {
|
||||
role := ""
|
||||
tagNames := make([]string, 0, len(user.TagIDs))
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok {
|
||||
tagNames = append(tagNames, tag.Name)
|
||||
if tag.Permissions.AdminAccess && role == "" {
|
||||
role = tag.Name
|
||||
} else if role == "" {
|
||||
role = tag.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
plan := "standard"
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tag, ok := tagMap[tagID]; ok && strings.EqualFold(tag.Name, "admin") {
|
||||
plan = "unlimited"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isInvite := strings.HasPrefix(user.PasswordHash, "invite/")
|
||||
status := userStatus(user.Disabled)
|
||||
if isInvite {
|
||||
status = "pending"
|
||||
}
|
||||
|
||||
rows[i] = UserRow{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
Status: status,
|
||||
Role: role,
|
||||
TagIDs: user.TagIDs,
|
||||
Tags: strings.Join(tagNames, ", "),
|
||||
Plan: plan,
|
||||
PolicySummary: "system default",
|
||||
BoxCount: 0,
|
||||
APIKeyCount: 0,
|
||||
CreatedAt: formatTime(user.CreatedAt),
|
||||
LastSeen: "-",
|
||||
Disabled: user.Disabled,
|
||||
IsCurrent: false,
|
||||
IsInvite: isInvite,
|
||||
}
|
||||
}
|
||||
|
||||
return UserPage{
|
||||
Rows: rows,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
TotalPages: totalPages,
|
||||
Stats: stats,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userStatus(disabled bool) string {
|
||||
if disabled {
|
||||
return "disabled"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func (store *Store) BulkSetUsersDisabled(ids []string, disabled bool) error {
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, id := range ids {
|
||||
var user User
|
||||
if err := getJSON(txn, userKey(id), &user); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
user.Disabled = disabled
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
if err := putJSON(txn, userKey(id), user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) RevokeUserSessions(userID string) error {
|
||||
tokens, err := store.sessionTokensForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return store.db.Update(func(txn *badger.Txn) error {
|
||||
for _, token := range tokens {
|
||||
if err := txn.Delete(sessionKey(token)); err != nil && !errors.Is(err, badger.ErrKeyNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (store *Store) BulkRevokeUserSessions(ids []string) error {
|
||||
for _, id := range ids {
|
||||
if err := store.RevokeUserSessions(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) sessionTokensForUser(userID string) ([]string, error) {
|
||||
tokens := []string{}
|
||||
err := store.db.View(func(txn *badger.Txn) error {
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.Prefix = []byte("session/")
|
||||
it := txn.NewIterator(opts)
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
var session Session
|
||||
if err := it.Item().Value(func(data []byte) error {
|
||||
return json.Unmarshal(data, &session)
|
||||
}); err != nil {
|
||||
continue
|
||||
}
|
||||
if session.UserID == userID {
|
||||
tokens = append(tokens, session.Token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
func (store *Store) CountAdminUsers(adminTagID string) (int, error) {
|
||||
users, err := store.ListUsers()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, user := range users {
|
||||
if user.Disabled {
|
||||
continue
|
||||
}
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTagID {
|
||||
count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateUserWithoutPassword(username string, email string, tagIDs []string) (User, error) {
|
||||
hash, err := helpers.RandomHexID(32)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user := User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: "invite/" + hash,
|
||||
TagIDs: uniqueStrings(tagIDs),
|
||||
Disabled: true,
|
||||
}
|
||||
if err := store.CreateUser(&user); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
221
lib/metastore/tags.go
Normal file
221
lib/metastore/tags.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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,
|
||||
AdminUsersView: 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))
|
||||
}
|
||||
@@ -41,19 +41,28 @@ type BoxFile struct {
|
||||
}
|
||||
|
||||
type BoxManifest struct {
|
||||
Files []BoxFile `json:"files"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RetentionKey string `json:"retention_key"`
|
||||
RetentionLabel string `json:"retention_label"`
|
||||
RetentionSecs int64 `json:"retention_seconds"`
|
||||
PasswordSalt string `json:"password_salt,omitempty"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
DisableZip bool `json:"disable_zip,omitempty"`
|
||||
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||
Consumed bool `json:"consumed,omitempty"`
|
||||
Files []BoxFile `json:"files"`
|
||||
OwnerID string `json:"owner_id,omitempty"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
Activity []BoxActivity `json:"activity,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RetentionKey string `json:"retention_key"`
|
||||
RetentionLabel string `json:"retention_label"`
|
||||
RetentionSecs int64 `json:"retention_seconds"`
|
||||
PasswordSalt string `json:"password_salt,omitempty"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
PasswordHashAlg string `json:"password_hash_alg,omitempty"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
DisableZip bool `json:"disable_zip,omitempty"`
|
||||
OneTimeDownload bool `json:"one_time_download,omitempty"`
|
||||
Consumed bool `json:"consumed,omitempty"`
|
||||
}
|
||||
|
||||
type BoxActivity struct {
|
||||
At time.Time `json:"at"`
|
||||
Message string `json:"message"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
type BoxSummary struct {
|
||||
|
||||
@@ -3,7 +3,6 @@ package routing
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type Handlers struct {
|
||||
Health gin.HandlerFunc
|
||||
Index gin.HandlerFunc
|
||||
ShowBox gin.HandlerFunc
|
||||
BoxLogin gin.HandlerFunc
|
||||
@@ -17,29 +16,9 @@ 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
|
||||
AdminActivity gin.HandlerFunc
|
||||
AdminSecurity gin.HandlerFunc
|
||||
AdminAlertsAction gin.HandlerFunc
|
||||
AdminSecurityAction 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)
|
||||
@@ -57,25 +36,4 @@ 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.POST("/alerts/actions", handlers.AdminAlertsAction)
|
||||
protected.GET("/boxes", handlers.AdminBoxes)
|
||||
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||
protected.GET("/users", handlers.AdminUsers)
|
||||
protected.GET("/activity", handlers.AdminActivity)
|
||||
protected.GET("/security", handlers.AdminSecurity)
|
||||
protected.POST("/security/actions", handlers.AdminSecurityAction)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
IPWhitelist string
|
||||
AdminIPWhitelist string
|
||||
LoginWindowSeconds int64
|
||||
LoginMaxAttempts int
|
||||
BanSeconds int64
|
||||
ScanWindowSeconds int64
|
||||
ScanMaxAttempts int
|
||||
UploadWindowSeconds int64
|
||||
UploadMaxRequests int
|
||||
UploadMaxBytes int64
|
||||
}
|
||||
|
||||
type Guard struct {
|
||||
mu sync.Mutex
|
||||
failedLogins map[string][]time.Time
|
||||
scanAttempts map[string][]time.Time
|
||||
uploadEvents map[string][]uploadEvent
|
||||
bannedUntil map[string]time.Time
|
||||
ipWhitelist map[string]bool
|
||||
adminWhitelist map[string]bool
|
||||
}
|
||||
|
||||
type uploadEvent struct {
|
||||
at time.Time
|
||||
bytes int64
|
||||
}
|
||||
|
||||
type BanEntry struct {
|
||||
IP string `json:"ip"`
|
||||
Until time.Time `json:"until"`
|
||||
}
|
||||
|
||||
func NewGuard() *Guard {
|
||||
return &Guard{
|
||||
failedLogins: map[string][]time.Time{},
|
||||
scanAttempts: map[string][]time.Time{},
|
||||
uploadEvents: map[string][]uploadEvent{},
|
||||
bannedUntil: map[string]time.Time{},
|
||||
ipWhitelist: map[string]bool{},
|
||||
adminWhitelist: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) Reload(cfg Config) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.ipWhitelist = parseList(cfg.IPWhitelist)
|
||||
g.adminWhitelist = parseList(cfg.AdminIPWhitelist)
|
||||
}
|
||||
|
||||
func (g *Guard) IsWhitelisted(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.ipWhitelist[ip]
|
||||
}
|
||||
|
||||
func (g *Guard) IsAdminWhitelisted(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.adminWhitelist[ip] || g.ipWhitelist[ip]
|
||||
}
|
||||
|
||||
func (g *Guard) IsBanned(ip string) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
until, ok := g.bannedUntil[ip]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().UTC().After(until) {
|
||||
delete(g.bannedUntil, ip)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (g *Guard) Ban(ip string, seconds int64) {
|
||||
if seconds <= 0 || ip == "" {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.bannedUntil[ip] = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
|
||||
}
|
||||
|
||||
func (g *Guard) BanUntil(ip string, until time.Time) {
|
||||
if ip == "" || until.IsZero() {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.bannedUntil[ip] = until.UTC()
|
||||
}
|
||||
|
||||
func (g *Guard) Unban(ip string) {
|
||||
if ip == "" {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
delete(g.bannedUntil, ip)
|
||||
}
|
||||
|
||||
func (g *Guard) BanList() []BanEntry {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
out := make([]BanEntry, 0, len(g.bannedUntil))
|
||||
for ip, until := range g.bannedUntil {
|
||||
if now.After(until) {
|
||||
delete(g.bannedUntil, ip)
|
||||
continue
|
||||
}
|
||||
out = append(out, BanEntry{IP: ip, Until: until})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].Until.Before(out[j].Until)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (g *Guard) RegisterFailedLogin(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) {
|
||||
if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
|
||||
attempts := pruneTimes(g.failedLogins[ip], cutoff)
|
||||
attempts = append(attempts, now)
|
||||
g.failedLogins[ip] = attempts
|
||||
if len(attempts) >= maxAttempts {
|
||||
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
|
||||
return true, len(attempts)
|
||||
}
|
||||
return false, len(attempts)
|
||||
}
|
||||
|
||||
func (g *Guard) RegisterScanAttempt(ip string, windowSeconds int64, maxAttempts int, banSeconds int64) (bool, int) {
|
||||
if ip == "" || maxAttempts <= 0 || windowSeconds <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
|
||||
attempts := pruneTimes(g.scanAttempts[ip], cutoff)
|
||||
attempts = append(attempts, now)
|
||||
g.scanAttempts[ip] = attempts
|
||||
if len(attempts) >= maxAttempts {
|
||||
g.bannedUntil[ip] = now.Add(time.Duration(banSeconds) * time.Second)
|
||||
return true, len(attempts)
|
||||
}
|
||||
return false, len(attempts)
|
||||
}
|
||||
|
||||
func (g *Guard) AllowUpload(ip string, size int64, windowSeconds int64, maxRequests int, maxBytes int64) (bool, int, int64) {
|
||||
if ip == "" || windowSeconds <= 0 || maxRequests <= 0 {
|
||||
return true, 0, 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-time.Duration(windowSeconds) * time.Second)
|
||||
events := g.uploadEvents[ip]
|
||||
kept := make([]uploadEvent, 0, len(events)+1)
|
||||
totalBytes := int64(0)
|
||||
for _, event := range events {
|
||||
if event.at.After(cutoff) {
|
||||
kept = append(kept, event)
|
||||
totalBytes += event.bytes
|
||||
}
|
||||
}
|
||||
nextCount := len(kept) + 1
|
||||
nextBytes := totalBytes + size
|
||||
if nextCount > maxRequests {
|
||||
return false, nextCount, nextBytes
|
||||
}
|
||||
if maxBytes > 0 && nextBytes > maxBytes {
|
||||
return false, nextCount, nextBytes
|
||||
}
|
||||
kept = append(kept, uploadEvent{at: now, bytes: size})
|
||||
g.uploadEvents[ip] = kept
|
||||
return true, nextCount, nextBytes
|
||||
}
|
||||
|
||||
func pruneTimes(values []time.Time, cutoff time.Time) []time.Time {
|
||||
kept := make([]time.Time, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value.After(cutoff) {
|
||||
kept = append(kept, value)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func parseList(raw string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, chunk := range strings.Split(raw, ",") {
|
||||
value := strings.TrimSpace(chunk)
|
||||
if value != "" {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
386
lib/server/account_alerts.go
Normal file
386
lib/server/account_alerts.go
Normal file
@@ -0,0 +1,386 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type AlertPageView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters AlertFiltersView
|
||||
Stats AlertStatsView
|
||||
Alerts []AlertRowView
|
||||
SelectedAlert *AlertRowView
|
||||
Groups []string
|
||||
CanManageAlerts bool
|
||||
}
|
||||
|
||||
type AlertFiltersView struct {
|
||||
Query string
|
||||
Severity string
|
||||
Status string
|
||||
Group string
|
||||
Sort string
|
||||
}
|
||||
|
||||
type AlertStatsView struct {
|
||||
Open int
|
||||
Acknowledged int
|
||||
Closed int
|
||||
High int
|
||||
Medium int
|
||||
Low int
|
||||
}
|
||||
|
||||
type AlertRowView struct {
|
||||
ID string
|
||||
Title string
|
||||
Description string
|
||||
Severity string
|
||||
Status string
|
||||
Code string
|
||||
Trace string
|
||||
Group string
|
||||
MetadataPretty string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlerts(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_alerts.html", page)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertAcknowledge(ctx *gin.Context) {
|
||||
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||
return app.AcknowledgeAlert(ctx, actor, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertClose(ctx *gin.Context) {
|
||||
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
|
||||
return app.CloseAlert(ctx, actor, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkAcknowledge(ctx *gin.Context) {
|
||||
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||
return app.BulkAcknowledgeAlerts(ctx, actor, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkClose(ctx *gin.Context) {
|
||||
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
|
||||
return app.BulkCloseAlerts(ctx, actor, ids)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertsExport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.Header("Content-Disposition", `attachment; filename="warpbox-alerts.json"`)
|
||||
ctx.JSON(http.StatusOK, gin.H{"alerts": page.Alerts, "filters": page.Filters, "stats": page.Stats})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertAction(ctx *gin.Context, action func(metastore.User, string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(actor, ctx.Param("id")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountAlertBulkAction(ctx *gin.Context, action func(metastore.User, []string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(actor, ctx.PostFormArray("alert_ids")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
|
||||
}
|
||||
|
||||
func (app *App) CreateAlert(ctx *gin.Context, actor metastore.User, input metastore.AlertInput) (metastore.Alert, error) {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return metastore.Alert{}, err
|
||||
}
|
||||
if input.CreatedBy == "" {
|
||||
input.CreatedBy = actor.Username
|
||||
}
|
||||
return app.store.CreateAlert(input)
|
||||
}
|
||||
|
||||
func (app *App) ListAlerts(ctx *gin.Context, actor metastore.User, filters metastore.AlertFilters) (AlertPageView, error) {
|
||||
if err := app.requireAlertView(ctx); err != nil {
|
||||
return AlertPageView{}, err
|
||||
}
|
||||
alerts, err := app.store.ListAlerts(filters)
|
||||
if err != nil {
|
||||
return AlertPageView{}, err
|
||||
}
|
||||
rows := make([]AlertRowView, 0, len(alerts))
|
||||
stats := AlertStatsView{}
|
||||
groupSet := map[string]bool{}
|
||||
for _, alert := range alerts {
|
||||
row := alertRowView(alert)
|
||||
rows = append(rows, row)
|
||||
groupSet[row.Group] = true
|
||||
switch alert.Status {
|
||||
case metastore.AlertStatusAcknowledged:
|
||||
stats.Acknowledged++
|
||||
case metastore.AlertStatusClosed:
|
||||
stats.Closed++
|
||||
default:
|
||||
stats.Open++
|
||||
}
|
||||
switch alert.Severity {
|
||||
case metastore.AlertSeverityHigh:
|
||||
stats.High++
|
||||
case metastore.AlertSeverityMedium:
|
||||
stats.Medium++
|
||||
default:
|
||||
stats.Low++
|
||||
}
|
||||
}
|
||||
groups := make([]string, 0, len(groupSet))
|
||||
for group := range groupSet {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
groups = []string{"system"}
|
||||
}
|
||||
|
||||
nav := app.accountNavView(ctx, "alerts")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
|
||||
var selected *AlertRowView
|
||||
if len(rows) > 0 {
|
||||
selected = &rows[0]
|
||||
}
|
||||
return AlertPageView{
|
||||
PageTitle: "WarpBox Alerts",
|
||||
WindowTitle: "WarpBox Alerts",
|
||||
WindowIcon: "!",
|
||||
PageScripts: []string{"/static/js/account-alerts.js"},
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: AlertFiltersView{Query: filters.Query, Severity: filters.Severity, Status: filters.Status, Group: filters.Group, Sort: filters.Sort},
|
||||
Stats: stats,
|
||||
Alerts: rows,
|
||||
SelectedAlert: selected,
|
||||
Groups: groups,
|
||||
CanManageAlerts: currentAccountPermissions(ctx).AdminAccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) AcknowledgeAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.AcknowledgeAlert(id)
|
||||
}
|
||||
|
||||
func (app *App) CloseAlert(ctx *gin.Context, actor metastore.User, id string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.CloseAlert(id)
|
||||
}
|
||||
|
||||
func (app *App) BulkAcknowledgeAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range uniqueNonEmpty(ids) {
|
||||
if err := app.store.AcknowledgeAlert(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) BulkCloseAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
if err := app.requireAlertManage(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range uniqueNonEmpty(ids) {
|
||||
if err := app.store.CloseAlert(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) EmitSystemAlert(code string, severity string, title string, description string, trace string, metadata any) error {
|
||||
raw, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
log.Printf("alert metadata marshal failed: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = app.store.CreateAlert(metastore.AlertInput{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: raw,
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("alert persistence failed: %v", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (app *App) requireAlertView(ctx *gin.Context) error {
|
||||
if !currentAccountPermissions(ctx).AdminAccess {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) requireAlertManage(ctx *gin.Context) error {
|
||||
if !currentAccountPermissions(ctx).AdminAccess {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func accountAlertFiltersFromRequest(ctx *gin.Context) metastore.AlertFilters {
|
||||
return metastore.AlertFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Severity: emptyAsAll(ctx.Query("severity")),
|
||||
Status: emptyAsAll(ctx.Query("status")),
|
||||
Group: emptyAsAll(ctx.Query("group")),
|
||||
Sort: emptyAsNewest(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func emptyAsAll(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "all"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func emptyAsNewest(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "newest"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func alertRowView(alert metastore.Alert) AlertRowView {
|
||||
return AlertRowView{
|
||||
ID: alert.ID,
|
||||
Title: alert.Title,
|
||||
Description: alert.Description,
|
||||
Severity: alert.Severity,
|
||||
Status: alert.Status,
|
||||
Code: alert.Code,
|
||||
Trace: alert.Trace,
|
||||
Group: alertGroupFromTrace(alert.Trace),
|
||||
MetadataPretty: prettyAlertMetadata(alert.Metadata),
|
||||
CreatedAt: formatAdminTime(alert.CreatedAt),
|
||||
UpdatedAt: formatAdminTime(alert.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func prettyAlertMetadata(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
pretty, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return string(pretty)
|
||||
}
|
||||
|
||||
func alertGroupFromTrace(trace string) string {
|
||||
trace = strings.TrimSpace(trace)
|
||||
if trace == "" {
|
||||
return "system"
|
||||
}
|
||||
before, _, found := strings.Cut(trace, ".")
|
||||
if !found || before == "" {
|
||||
return "system"
|
||||
}
|
||||
return strings.ToLower(before)
|
||||
}
|
||||
|
||||
func (app *App) openAlertSummary() (int, string) {
|
||||
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen})
|
||||
if err != nil {
|
||||
return 0, "ok"
|
||||
}
|
||||
severity := "ok"
|
||||
for _, alert := range alerts {
|
||||
if alert.Severity == metastore.AlertSeverityHigh {
|
||||
return len(alerts), "danger"
|
||||
}
|
||||
if alert.Severity == metastore.AlertSeverityMedium {
|
||||
severity = "warning"
|
||||
} else if severity == "ok" {
|
||||
severity = "info"
|
||||
}
|
||||
}
|
||||
return len(alerts), severity
|
||||
}
|
||||
|
||||
func uniqueNonEmpty(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
155
lib/server/account_alerts_test.go
Normal file
155
lib/server/account_alerts_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountAlertsPageListsAndFiltersAlerts(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
createTestAlert(t, app, "701", metastore.AlertSeverityHigh, "storage.connector.health_failed")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/alerts?severity=high", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected alerts page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "storage.connector.health_failed") {
|
||||
t.Fatal("expected high severity alert")
|
||||
}
|
||||
if strings.Contains(body, "thumbnail.generate.failed") {
|
||||
t.Fatal("did not expect medium severity alert in high filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountAlertAcknowledgeAndClose(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected acknowledge redirect, got %d", response.Code)
|
||||
}
|
||||
updated, ok, err := app.store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if updated.Status != metastore.AlertStatusAcknowledged {
|
||||
t.Fatalf("expected acknowledged alert, got %s", updated.Status)
|
||||
}
|
||||
|
||||
response = postAlertAction(router, session, "/account/alerts/"+alert.ID+"/close", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected close redirect, got %d", response.Code)
|
||||
}
|
||||
updated, ok, err = app.store.GetAlert(alert.ID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if updated.Status != metastore.AlertStatusClosed {
|
||||
t.Fatalf("expected closed alert, got %s", updated.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountAlertManagePermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
regular, err := app.store.CreateUserWithPassword("regular-alerts", "regular-alerts@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, regular)
|
||||
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardUsesRealAlertCount(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "1 alerts") {
|
||||
t.Fatal("expected dashboard alert chip/count")
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "Thumbnail alert") {
|
||||
t.Fatal("expected dashboard alert preview")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountAlertsExportJSON(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/alerts/export.json", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected export success, got %d", response.Code)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if _, ok := payload["alerts"]; !ok {
|
||||
t.Fatal("expected alerts export shape")
|
||||
}
|
||||
}
|
||||
|
||||
func createTestAlert(t *testing.T, app *App, code string, severity string, trace string) metastore.Alert {
|
||||
t.Helper()
|
||||
alert, err := app.store.CreateAlert(metastore.AlertInput{
|
||||
Title: "Thumbnail alert",
|
||||
Description: "Alert test description.",
|
||||
Severity: severity,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
|
||||
CreatedBy: "system",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAlert returned error: %v", err)
|
||||
}
|
||||
return alert
|
||||
}
|
||||
|
||||
func postAlertAction(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set("csrf_token", session.CSRFToken)
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
194
lib/server/account_auth.go
Normal file
194
lib/server/account_auth.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
const accountSessionCookie = "warpbox_account_session"
|
||||
|
||||
func (app *App) registerAccountRoutes(router *gin.Engine) {
|
||||
account := router.Group("/account")
|
||||
account.Use(noStoreAdminHeaders)
|
||||
account.GET("/login", app.handleAccountLogin)
|
||||
account.POST("/login", app.handleAccountLoginPost)
|
||||
|
||||
protected := account.Group("")
|
||||
protected.Use(app.requireAccountSession)
|
||||
protected.GET("", app.handleAccountDashboard)
|
||||
protected.GET("/", app.handleAccountDashboard)
|
||||
protected.POST("/logout", app.handleAccountLogout)
|
||||
protected.GET("/settings", app.handleAccountSettings)
|
||||
protected.POST("/settings", app.handleAccountSettingsPost)
|
||||
protected.POST("/settings/reset", app.handleAccountSettingsReset)
|
||||
protected.GET("/settings/export.json", app.handleAccountSettingsExport)
|
||||
protected.POST("/settings/import.json", app.handleAccountSettingsImport)
|
||||
protected.GET("/alerts", app.handleAccountAlerts)
|
||||
protected.GET("/alerts/export.json", app.handleAccountAlertsExport)
|
||||
protected.POST("/alerts/bulk/acknowledge", app.handleAccountAlertBulkAcknowledge)
|
||||
protected.POST("/alerts/bulk/close", app.handleAccountAlertBulkClose)
|
||||
protected.POST("/alerts/:id/acknowledge", app.handleAccountAlertAcknowledge)
|
||||
protected.POST("/alerts/:id/close", app.handleAccountAlertClose)
|
||||
protected.GET("/boxes", app.handleAccountBoxes)
|
||||
protected.GET("/boxes/export.csv", app.handleAccountBoxesExport)
|
||||
protected.POST("/boxes/bulk/expire", app.handleAccountBoxesBulkExpire)
|
||||
protected.POST("/boxes/bulk/delete", app.handleAccountBoxesBulkDelete)
|
||||
protected.POST("/boxes/bulk/bump-expiry", app.handleAccountBoxesBulkBumpExpiry)
|
||||
protected.POST("/boxes/delete-largest", app.handleAccountBoxesDeleteLargest)
|
||||
protected.GET("/boxes/:id", app.handleAccountBoxManager)
|
||||
protected.POST("/boxes/:id", app.handleAccountBoxUpdate)
|
||||
protected.POST("/boxes/:id/extend", app.handleAccountBoxExtend)
|
||||
protected.POST("/boxes/:id/expire", app.handleAccountBoxExpire)
|
||||
protected.POST("/boxes/:id/delete", app.handleAccountBoxDelete)
|
||||
protected.POST("/boxes/:id/password", app.handleAccountBoxPassword)
|
||||
protected.POST("/boxes/:id/password/remove", app.handleAccountBoxPasswordRemove)
|
||||
protected.POST("/boxes/:id/files/delete", app.handleAccountBoxFilesDelete)
|
||||
protected.GET("/users", app.handleAccountUsers)
|
||||
protected.POST("/users", app.handleAccountUsersPost)
|
||||
protected.POST("/users/bulk/disable", app.handleAccountUsersBulkDisable)
|
||||
protected.POST("/users/bulk/enable", app.handleAccountUsersBulkEnable)
|
||||
protected.POST("/users/bulk/revoke-sessions", app.handleAccountUsersBulkRevokeSessions)
|
||||
protected.POST("/users/:id/invite/resend", app.handleAccountUsersResendInvite)
|
||||
protected.GET("/users/:id", app.handleAccountUserEdit)
|
||||
protected.POST("/users/:id", app.handleAccountUserEditPost)
|
||||
protected.POST("/users/:id/disable", app.handleAccountUserDisable)
|
||||
protected.POST("/users/:id/enable", app.handleAccountUserEnable)
|
||||
protected.POST("/users/:id/password/reset", app.handleAccountUserPasswordReset)
|
||||
protected.POST("/users/:id/sessions/revoke", app.handleAccountUserRevokeSessions)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLogin(ctx *gin.Context) {
|
||||
if app.isAccountSessionValid(ctx) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
return
|
||||
}
|
||||
app.renderAccountLogin(ctx, "")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLoginPost(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled {
|
||||
app.renderAccountLogin(ctx, "Account 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.renderAccountLogin(ctx, "The username or password was not accepted.")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := app.permissionsForUser(user); err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load permissions")
|
||||
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(accountSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/account", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountLogout(ctx *gin.Context) {
|
||||
if token, err := ctx.Cookie(accountSessionCookie); err == nil {
|
||||
_ = app.store.DeleteSession(token)
|
||||
}
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(accountSessionCookie, "", -1, "/account", "", app.config.AdminCookieSecure, true)
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
}
|
||||
|
||||
func (app *App) requireAccountSession(ctx *gin.Context) {
|
||||
token, err := ctx.Cookie(accountSessionCookie)
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
session, ok, err := app.store.GetSession(token)
|
||||
if err != nil || !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/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, "/account/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
perms, err := app.permissionsForUser(user)
|
||||
if err != nil {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Set("accountUser", user)
|
||||
ctx.Set("adminUser", user)
|
||||
ctx.Set("accountPerms", perms)
|
||||
ctx.Set("adminPerms", perms)
|
||||
ctx.Set("accountSession", session)
|
||||
ctx.Set("accountCSRFToken", session.CSRFToken)
|
||||
ctx.Set("adminCSRFToken", session.CSRFToken)
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (app *App) isAccountSessionValid(ctx *gin.Context) bool {
|
||||
token, err := ctx.Cookie(accountSessionCookie)
|
||||
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
|
||||
}
|
||||
_, err = app.permissionsForUser(user)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (app *App) renderAccountLogin(ctx *gin.Context, errorMessage string) {
|
||||
ctx.HTML(http.StatusOK, "account_login.html", gin.H{
|
||||
"PageTitle": "WarpBox Account Login",
|
||||
"AdminLoginEnabled": app.adminLoginEnabled,
|
||||
"AccountLoginEnabled": app.adminLoginEnabled,
|
||||
"Error": errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
func currentAccountUser(ctx *gin.Context) (metastore.User, bool) {
|
||||
if current, ok := ctx.Get("accountUser"); ok {
|
||||
if user, ok := current.(metastore.User); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
if current, ok := ctx.Get("adminUser"); ok {
|
||||
if user, ok := current.(metastore.User); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
return metastore.User{}, false
|
||||
}
|
||||
515
lib/server/account_box_manager.go
Normal file
515
lib/server/account_box_manager.go
Normal file
@@ -0,0 +1,515 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
type BoxManagerView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Box BoxManagerSummary
|
||||
Files []BoxManagerFileRow
|
||||
Policy BoxActionPolicy
|
||||
PolicyJSON string
|
||||
Activity []BoxManagerActivityRow
|
||||
Error string
|
||||
}
|
||||
|
||||
type BoxManagerSummary struct {
|
||||
ID string
|
||||
Owner string
|
||||
Status string
|
||||
Storage string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Flags string
|
||||
OpenURL string
|
||||
DisableZip bool
|
||||
OneTimeDownload bool
|
||||
}
|
||||
|
||||
type BoxManagerFileRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Size string
|
||||
Status string
|
||||
Download string
|
||||
}
|
||||
|
||||
type BoxManagerActivityRow struct {
|
||||
At string
|
||||
Message string
|
||||
Actor string
|
||||
}
|
||||
|
||||
type BoxActionPolicy struct {
|
||||
CanViewManager bool `json:"can_view_manager"`
|
||||
CanEditMetadata bool `json:"can_edit_metadata"`
|
||||
CanEditSharingRules bool `json:"can_edit_sharing_rules"`
|
||||
CanEditPassword bool `json:"can_edit_password"`
|
||||
CanDeleteBox bool `json:"can_delete_box"`
|
||||
CanDeleteFiles bool `json:"can_delete_files"`
|
||||
CanExtendExpiry bool `json:"can_extend_expiry"`
|
||||
MaxExtensionSeconds int64 `json:"max_extension_seconds"`
|
||||
MaxRefreshCount int `json:"max_refresh_count"`
|
||||
MaxTotalLifetimeSecs int64 `json:"max_total_lifetime_seconds"`
|
||||
Reasons map[string]string `json:"reasons,omitempty"`
|
||||
}
|
||||
|
||||
type BoxRulesInput struct {
|
||||
DisableZip bool
|
||||
OneTimeDownload bool
|
||||
}
|
||||
|
||||
type BoxPasswordInput struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxManager(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
view, err := app.GetBoxManager(ctx, actor, ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxUpdate(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
input := BoxRulesInput{
|
||||
DisableZip: ctx.PostForm("disable_zip") == "true",
|
||||
OneTimeDownload: ctx.PostForm("one_time_download") == "true",
|
||||
}
|
||||
if err := app.UpdateBoxRules(ctx, actor, ctx.Param("id"), input); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxExtend(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
seconds := parsePositiveInt64Default(ctx.PostForm("extend_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
|
||||
if err := app.ExtendBoxExpiry(ctx, actor, ctx.Param("id"), seconds); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxExpire(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.ExpireBoxNow(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxDelete(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.DeleteBox(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxPassword(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.SetBoxPassword(ctx, actor, ctx.Param("id"), BoxPasswordInput{Password: ctx.PostForm("password")}); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxPasswordRemove(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.RemoveBoxPassword(ctx, actor, ctx.Param("id")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxFilesDelete(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.DeleteBoxFiles(ctx, actor, ctx.Param("id"), ctx.PostFormArray("file_ids")); err != nil {
|
||||
app.renderBoxManagerError(ctx, actor, ctx.Param("id"), err)
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes/"+ctx.Param("id"))
|
||||
}
|
||||
|
||||
func (app *App) GetBoxManager(ctx *gin.Context, actor metastore.User, boxID string) (BoxManagerView, error) {
|
||||
record, manifest, err := app.loadBoxForManager(boxID)
|
||||
if err != nil {
|
||||
return BoxManagerView{}, err
|
||||
}
|
||||
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||
if !policy.CanViewManager {
|
||||
return BoxManagerView{}, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||
}
|
||||
files := make([]BoxManagerFileRow, 0, len(manifest.Files))
|
||||
for _, file := range boxstore.DecorateFiles(boxID, manifest.Files) {
|
||||
files = append(files, BoxManagerFileRow{ID: file.ID, Name: file.Name, Size: file.SizeLabel, Status: file.StatusLabel, Download: file.DownloadPath})
|
||||
}
|
||||
policyJSON, _ := json.MarshalIndent(policy, "", " ")
|
||||
nav := app.accountNavView(ctx, "boxes")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
return BoxManagerView{
|
||||
PageTitle: "WarpBox Box Manager",
|
||||
WindowTitle: "WarpBox Box Manager",
|
||||
WindowIcon: "B",
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Box: BoxManagerSummary{
|
||||
ID: record.ID,
|
||||
Owner: boxOwnerLabel(record),
|
||||
Status: boxStatus(record),
|
||||
Storage: helpers.FormatBytes(record.TotalSize),
|
||||
CreatedAt: formatAdminTime(record.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(record.ExpiresAt),
|
||||
Flags: boxFlags(record),
|
||||
OpenURL: "/box/" + record.ID,
|
||||
DisableZip: record.DisableZip,
|
||||
OneTimeDownload: record.OneTimeDownload,
|
||||
},
|
||||
Files: files,
|
||||
Policy: policy,
|
||||
PolicyJSON: string(policyJSON),
|
||||
Activity: boxActivityRows(manifest.Activity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) UpdateBoxRules(ctx *gin.Context, actor metastore.User, boxID string, input BoxRulesInput) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditSharingRules {
|
||||
return fmt.Errorf(policyReason(policy, "sharing", "sharing edits disabled"))
|
||||
}
|
||||
manifest.DisableZip = input.DisableZip
|
||||
manifest.OneTimeDownload = input.OneTimeDownload
|
||||
appendBoxActivity(&manifest, actor.Username, "sharing rules updated")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) ExtendBoxExpiry(ctx *gin.Context, actor metastore.User, boxID string, amount int64) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanExtendExpiry {
|
||||
return fmt.Errorf(policyReason(policy, "extend", "expiry refresh disabled"))
|
||||
}
|
||||
if amount <= 0 {
|
||||
return fmt.Errorf("extension amount must be positive")
|
||||
}
|
||||
if policy.MaxExtensionSeconds > 0 && amount > policy.MaxExtensionSeconds {
|
||||
return fmt.Errorf("extension exceeds maximum single extension")
|
||||
}
|
||||
if policy.MaxRefreshCount > 0 && record.RefreshCount >= policy.MaxRefreshCount {
|
||||
return fmt.Errorf("refresh count limit reached")
|
||||
}
|
||||
base := manifest.ExpiresAt
|
||||
if base.IsZero() || time.Now().UTC().After(base) {
|
||||
base = time.Now().UTC()
|
||||
}
|
||||
next := base.Add(time.Duration(amount) * time.Second)
|
||||
if policy.MaxTotalLifetimeSecs > 0 && next.After(manifest.CreatedAt.Add(time.Duration(policy.MaxTotalLifetimeSecs)*time.Second)) {
|
||||
return fmt.Errorf("extension exceeds maximum total lifetime")
|
||||
}
|
||||
manifest.ExpiresAt = next
|
||||
record.RefreshCount++
|
||||
appendBoxActivity(&manifest, actor.Username, "expiry extended")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) ExpireBoxNow(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditMetadata {
|
||||
return fmt.Errorf(policyReason(policy, "edit", "edit disabled"))
|
||||
}
|
||||
manifest.ExpiresAt = time.Now().UTC().Add(-time.Second)
|
||||
appendBoxActivity(&manifest, actor.Username, "box expired")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) DeleteBox(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = manifest
|
||||
if !policy.CanDeleteBox {
|
||||
return fmt.Errorf(policyReason(policy, "delete", "delete disabled"))
|
||||
}
|
||||
if err := boxstore.DeleteBox(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.store.DeleteBoxRecord(record.ID)
|
||||
}
|
||||
|
||||
func (app *App) SetBoxPassword(ctx *gin.Context, actor metastore.User, boxID string, input BoxPasswordInput) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditPassword {
|
||||
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||
}
|
||||
password := strings.TrimSpace(input.Password)
|
||||
if password == "" {
|
||||
return fmt.Errorf("password cannot be empty")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := helpers.RandomHexID(16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest.PasswordHash = string(hash)
|
||||
manifest.PasswordHashAlg = "bcrypt"
|
||||
manifest.AuthToken = token
|
||||
appendBoxActivity(&manifest, actor.Username, "password set")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) RemoveBoxPassword(ctx *gin.Context, actor metastore.User, boxID string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanEditPassword {
|
||||
return fmt.Errorf(policyReason(policy, "password", "password edits disabled"))
|
||||
}
|
||||
manifest.PasswordHash = ""
|
||||
manifest.PasswordHashAlg = ""
|
||||
manifest.PasswordSalt = ""
|
||||
manifest.AuthToken = ""
|
||||
appendBoxActivity(&manifest, actor.Username, "password removed")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) DeleteBoxFiles(ctx *gin.Context, actor metastore.User, boxID string, fileIDs []string) error {
|
||||
record, manifest, policy, err := app.boxMutationContext(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !policy.CanDeleteFiles {
|
||||
return fmt.Errorf(policyReason(policy, "files", "file deletion disabled"))
|
||||
}
|
||||
fileIDs = uniqueNonEmpty(fileIDs)
|
||||
if len(fileIDs) == 0 {
|
||||
return fmt.Errorf("no files selected")
|
||||
}
|
||||
remove := map[string]bool{}
|
||||
for _, id := range fileIDs {
|
||||
remove[id] = true
|
||||
}
|
||||
kept := make([]models.BoxFile, 0, len(manifest.Files))
|
||||
for _, file := range manifest.Files {
|
||||
if remove[file.ID] {
|
||||
if path, ok := boxstore.SafeBoxFilePath(boxID, file.Name); ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
kept = append(kept, file)
|
||||
}
|
||||
manifest.Files = kept
|
||||
appendBoxActivity(&manifest, actor.Username, "files deleted")
|
||||
return app.saveManagedBox(record, manifest)
|
||||
}
|
||||
|
||||
func (app *App) renderBoxManagerError(ctx *gin.Context, actor metastore.User, boxID string, actionErr error) {
|
||||
view, err := app.GetBoxManager(ctx, actor, boxID)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, actionErr.Error())
|
||||
return
|
||||
}
|
||||
view.Error = actionErr.Error()
|
||||
ctx.HTML(http.StatusOK, "account_box_manager.html", view)
|
||||
}
|
||||
|
||||
func (app *App) boxMutationContext(ctx *gin.Context, actor metastore.User, boxID string) (metastore.BoxRecord, models.BoxManifest, BoxActionPolicy, error) {
|
||||
record, manifest, err := app.loadBoxForManager(boxID)
|
||||
if err != nil {
|
||||
return record, manifest, BoxActionPolicy{}, err
|
||||
}
|
||||
policy := app.resolveBoxPolicy(ctx, actor, record, manifest)
|
||||
if !policy.CanViewManager {
|
||||
return record, manifest, policy, fmt.Errorf(policyReason(policy, "view", "permission denied"))
|
||||
}
|
||||
return record, manifest, policy, nil
|
||||
}
|
||||
|
||||
func (app *App) loadBoxForManager(boxID string) (metastore.BoxRecord, models.BoxManifest, error) {
|
||||
if !boxstore.ValidBoxID(boxID) {
|
||||
return metastore.BoxRecord{}, models.BoxManifest{}, fmt.Errorf("invalid box id")
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(boxID)
|
||||
if err != nil {
|
||||
return record, models.BoxManifest{}, err
|
||||
}
|
||||
if !ok {
|
||||
return record, models.BoxManifest{}, fmt.Errorf("box not found")
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return record, manifest, err
|
||||
}
|
||||
return record, manifest, nil
|
||||
}
|
||||
|
||||
func (app *App) resolveBoxPolicy(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord, manifest models.BoxManifest) BoxActionPolicy {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
isAdmin := perms.AdminBoxesView
|
||||
isOwner := record.OwnerID != "" && record.OwnerID == actor.ID
|
||||
policy := BoxActionPolicy{
|
||||
MaxExtensionSeconds: app.config.BoxOwnerMaxRefreshAmountSeconds,
|
||||
MaxRefreshCount: app.config.BoxOwnerMaxRefreshCount,
|
||||
MaxTotalLifetimeSecs: app.config.BoxOwnerMaxTotalExpirySeconds,
|
||||
Reasons: map[string]string{},
|
||||
}
|
||||
if isAdmin {
|
||||
policy.CanViewManager = true
|
||||
policy.CanEditMetadata = true
|
||||
policy.CanEditSharingRules = true
|
||||
policy.CanEditPassword = true
|
||||
policy.CanDeleteBox = true
|
||||
policy.CanDeleteFiles = true
|
||||
policy.CanExtendExpiry = !manifest.OneTimeDownload
|
||||
return policy
|
||||
}
|
||||
if !isOwner {
|
||||
policy.Reasons["view"] = "not box owner"
|
||||
return policy
|
||||
}
|
||||
if !app.config.BoxOwnerEditEnabled {
|
||||
policy.Reasons["view"] = "box owner editing disabled"
|
||||
return policy
|
||||
}
|
||||
policy.CanViewManager = true
|
||||
policy.CanEditMetadata = true
|
||||
policy.CanEditSharingRules = true
|
||||
policy.CanDeleteBox = true
|
||||
policy.CanDeleteFiles = true
|
||||
if app.config.BoxOwnerPasswordEditEnabled {
|
||||
policy.CanEditPassword = true
|
||||
} else {
|
||||
policy.Reasons["password"] = "password editing disabled by policy"
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
policy.Reasons["extend"] = "refresh disabled by policy"
|
||||
} else if manifest.OneTimeDownload {
|
||||
policy.Reasons["extend"] = "one-time boxes cannot be refreshed"
|
||||
} else if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
|
||||
policy.Reasons["extend"] = "refresh count limit reached"
|
||||
} else {
|
||||
policy.CanExtendExpiry = true
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (app *App) saveManagedBox(record metastore.BoxRecord, manifest models.BoxManifest) error {
|
||||
if err := boxstore.WriteManifest(record.ID, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
next := boxRecordFromManifest(record.ID, manifest)
|
||||
next.RefreshCount = record.RefreshCount
|
||||
return app.store.UpsertBoxRecord(next)
|
||||
}
|
||||
|
||||
func appendBoxActivity(manifest *models.BoxManifest, actor string, message string) {
|
||||
manifest.Activity = append([]models.BoxActivity{{
|
||||
At: time.Now().UTC(),
|
||||
Actor: actor,
|
||||
Message: message,
|
||||
}}, manifest.Activity...)
|
||||
if len(manifest.Activity) > 12 {
|
||||
manifest.Activity = manifest.Activity[:12]
|
||||
}
|
||||
}
|
||||
|
||||
func boxActivityRows(activity []models.BoxActivity) []BoxManagerActivityRow {
|
||||
rows := make([]BoxManagerActivityRow, 0, len(activity))
|
||||
for _, item := range activity {
|
||||
rows = append(rows, BoxManagerActivityRow{At: formatAdminTime(item.At), Message: item.Message, Actor: item.Actor})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
rows = append(rows, BoxManagerActivityRow{At: "-", Message: "No box activity yet.", Actor: "system"})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func policyReason(policy BoxActionPolicy, key string, fallback string) string {
|
||||
if policy.Reasons != nil && policy.Reasons[key] != "" {
|
||||
return policy.Reasons[key]
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boxOwnerLabel(record metastore.BoxRecord) string {
|
||||
if record.OwnerUsername != "" {
|
||||
return record.OwnerUsername
|
||||
}
|
||||
return "guest"
|
||||
}
|
||||
219
lib/server/account_box_manager_test.go
Normal file
219
lib/server/account_box_manager_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountBoxManagerAdminCanViewAndEdit(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, admin)
|
||||
id := "abababababababababababababababab"
|
||||
createIndexedBox(t, app, id, "", "", 10, false)
|
||||
|
||||
response := getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected manager page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "WarpBox Box Manager") {
|
||||
t.Fatal("expected manager UI")
|
||||
}
|
||||
|
||||
form := url.Values{"disable_zip": []string{"true"}}
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id, form)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected update redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if !manifest.DisableZip {
|
||||
t.Fatal("expected sharing rule update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerViewAllowedAndDeniedByPolicy(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-view", "owner-view@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected owner manager page, got %d", response.Code)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = false
|
||||
response = getAccountBoxManager(router, session, id)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected owner denied by policy, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerRefreshLimits(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerMaxRefreshCount = 1
|
||||
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||
app.config.BoxOwnerMaxTotalExpirySeconds = 7200
|
||||
user, err := app.store.CreateUserWithPassword("owner-refresh", "owner-refresh@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected owner refresh success, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if record.RefreshCount != 1 {
|
||||
t.Fatalf("expected refresh count 1, got %d", record.RefreshCount)
|
||||
}
|
||||
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected refresh count rejection render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "refresh count") {
|
||||
t.Fatal("expected refresh count error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerOwnerRefreshRejectedOverMaxDuration(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerMaxRefreshAmountSeconds = 60
|
||||
user, err := app.store.CreateUserWithPassword("owner-duration", "owner-duration@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "dededededededededededededededede"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/extend", url.Values{"extend_seconds": []string{"120"}})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected max duration rejection render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "maximum single extension") {
|
||||
t.Fatal("expected max duration error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerPasswordSetRemovePermissions(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-pass", "owner-pass@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "efefefefefefefefefefefefefefefef"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/password", url.Values{"password": []string{"new-secret"}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected password set redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if manifest.PasswordHash == "" || manifest.AuthToken == "" {
|
||||
t.Fatal("expected password set")
|
||||
}
|
||||
|
||||
app.config.BoxOwnerPasswordEditEnabled = false
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/password/remove", nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected password permission render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "password editing disabled") {
|
||||
t.Fatal("expected password permission error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxManagerFileDeleteAndBoxDeletePermissions(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("owner-delete", "owner-delete@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "fafafafafafafafafafafafafafafafa"
|
||||
createIndexedBox(t, app, id, user.ID, user.Username, 10, false)
|
||||
manifest, err := boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
fileID := manifest.Files[0].ID
|
||||
|
||||
response := postAccountBoxForm(router, session, "/account/boxes/"+id+"/files/delete", url.Values{"file_ids": []string{fileID}})
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected file delete redirect, got %d", response.Code)
|
||||
}
|
||||
manifest, err = boxstore.ReadManifest(id)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadManifest returned error: %v", err)
|
||||
}
|
||||
if len(manifest.Files) != 0 {
|
||||
t.Fatalf("expected file removed, got %#v", manifest.Files)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = false
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected delete permission denied after policy disabled, got %d", response.Code)
|
||||
}
|
||||
|
||||
app.config.BoxOwnerEditEnabled = true
|
||||
response = postAccountBoxForm(router, session, "/account/boxes/"+id+"/delete", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected box delete redirect, got %d", response.Code)
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected box directory deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountBoxManager(router http.Handler, session metastore.Session, id string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/boxes/"+id, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postAccountBoxForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set("csrf_token", session.CSRFToken)
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
454
lib/server/account_boxes.go
Normal file
454
lib/server/account_boxes.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
type BoxIndexView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters BoxFiltersView
|
||||
Rows []BoxRowView
|
||||
Stats BoxIndexStats
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevURL string
|
||||
NextURL string
|
||||
CanManage bool
|
||||
PolicySummary string
|
||||
Error string
|
||||
}
|
||||
|
||||
type BoxFiltersView struct {
|
||||
Query string
|
||||
Owner string
|
||||
Status string
|
||||
Flag string
|
||||
Sort string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type BoxIndexStats struct {
|
||||
Visible int
|
||||
Total int
|
||||
Expired int
|
||||
Storage string
|
||||
}
|
||||
|
||||
type BoxRowView struct {
|
||||
ID string
|
||||
Owner string
|
||||
Status string
|
||||
FileCount int
|
||||
Size string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Flags string
|
||||
Policy string
|
||||
CanManage bool
|
||||
ManageURL string
|
||||
OpenURL string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxes(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
view, err := app.ListBoxes(ctx, actor, boxFiltersFromRequest(ctx), boxPageFromRequest(ctx))
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_boxes.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkExpire(ctx *gin.Context) {
|
||||
app.handleAccountBoxesBulkAction(ctx, app.ExpireBoxes)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkDelete(ctx *gin.Context) {
|
||||
app.handleAccountBoxesBulkAction(ctx, app.DeleteBoxes)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkBumpExpiry(ctx *gin.Context) {
|
||||
app.handleAccountBoxesBulkAction(ctx, func(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
seconds := parsePositiveInt64Default(ctx.PostForm("bump_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
|
||||
return app.BumpBoxExpiries(ctx, actor, ids, seconds)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesDeleteLargest(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
filters := boxFiltersFromRequest(ctx)
|
||||
filters.Sort = "largest"
|
||||
page := metastore.BoxPageRequest{Page: 1, PageSize: 25}
|
||||
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, 10)
|
||||
for _, row := range boxPage.Rows {
|
||||
if len(ids) == 10 {
|
||||
break
|
||||
}
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
if err := app.DeleteBoxes(ctx, actor, ids); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesExport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
page, err := app.visibleBoxRecords(ctx, actor, boxFiltersFromRequest(ctx), metastore.BoxPageRequest{Page: 1, PageSize: 100})
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
writer := csv.NewWriter(&buffer)
|
||||
_ = writer.Write([]string{"id", "owner", "status", "file_count", "total_size", "created_at", "expires_at", "flags"})
|
||||
for _, record := range page.Rows {
|
||||
_ = writer.Write([]string{record.ID, record.OwnerUsername, boxStatus(record), strconv.Itoa(record.FileCount), strconv.FormatInt(record.TotalSize, 10), record.CreatedAt.Format(time.RFC3339), record.ExpiresAt.Format(time.RFC3339), boxFlags(record)})
|
||||
}
|
||||
writer.Flush()
|
||||
ctx.Header("Content-Disposition", `attachment; filename="warpbox-boxes.csv"`)
|
||||
ctx.Data(http.StatusOK, "text/csv; charset=utf-8", buffer.Bytes())
|
||||
}
|
||||
|
||||
func (app *App) handleAccountBoxesBulkAction(ctx *gin.Context, action func(*gin.Context, metastore.User, []string) error) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := action(ctx, actor, ctx.PostFormArray("box_ids")); err != nil {
|
||||
ctx.String(http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
|
||||
}
|
||||
|
||||
func (app *App) ListBoxes(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (BoxIndexView, error) {
|
||||
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
|
||||
if err != nil {
|
||||
return BoxIndexView{}, err
|
||||
}
|
||||
rows := make([]BoxRowView, 0, len(boxPage.Rows))
|
||||
stats := BoxIndexStats{Visible: len(boxPage.Rows), Total: boxPage.Total}
|
||||
totalSize := int64(0)
|
||||
for _, record := range boxPage.Rows {
|
||||
totalSize += record.TotalSize
|
||||
if boxExpired(record) {
|
||||
stats.Expired++
|
||||
}
|
||||
rows = append(rows, app.boxRowView(ctx, actor, record))
|
||||
}
|
||||
stats.Storage = helpers.FormatBytes(totalSize)
|
||||
nav := app.accountNavView(ctx, "boxes")
|
||||
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
return BoxIndexView{
|
||||
PageTitle: "WarpBox Boxes",
|
||||
WindowTitle: "WarpBox Boxes",
|
||||
WindowIcon: "B",
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: BoxFiltersView{Query: filters.Query, Owner: filters.Owner, Status: filters.Status, Flag: filters.Flag, Sort: filters.Sort, PageSize: boxPage.PageSize},
|
||||
Rows: rows,
|
||||
Stats: stats,
|
||||
Page: boxPage.Page,
|
||||
PageSize: boxPage.PageSize,
|
||||
Total: boxPage.Total,
|
||||
TotalPages: boxPage.TotalPages,
|
||||
HasPrev: boxPage.HasPrev,
|
||||
HasNext: boxPage.HasNext,
|
||||
PrevURL: boxPageURL(ctx, boxPage.PrevPage),
|
||||
NextURL: boxPageURL(ctx, boxPage.NextPage),
|
||||
CanManage: currentAccountPermissions(ctx).AdminBoxesView,
|
||||
PolicySummary: app.boxPolicySummary(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) ExpireBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC().Add(-time.Second)
|
||||
for _, record := range records {
|
||||
manifest, err := boxstore.ReadManifest(record.ID)
|
||||
if err == nil {
|
||||
manifest.ExpiresAt = now
|
||||
_ = boxstore.WriteManifest(record.ID, manifest)
|
||||
}
|
||||
record.ExpiresAt = now
|
||||
if err := app.store.UpsertBoxRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) DeleteBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
|
||||
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if err := boxstore.DeleteBox(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.store.DeleteBoxRecord(record.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) BumpBoxExpiries(ctx *gin.Context, actor metastore.User, ids []string, seconds int64) error {
|
||||
if seconds <= 0 {
|
||||
return fmt.Errorf("bump expiry requires a positive duration")
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
return fmt.Errorf("box owner refresh policy is disabled")
|
||||
}
|
||||
if app.config.BoxOwnerMaxRefreshAmountSeconds > 0 && seconds > app.config.BoxOwnerMaxRefreshAmountSeconds {
|
||||
return fmt.Errorf("bump expiry exceeds maximum refresh amount")
|
||||
}
|
||||
records, err := app.authorizedBoxRecords(ctx, actor, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.OneTimeDownload {
|
||||
return fmt.Errorf("one-time boxes cannot be refreshed")
|
||||
}
|
||||
if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
|
||||
return fmt.Errorf("box refresh count limit reached")
|
||||
}
|
||||
base := record.ExpiresAt
|
||||
if base.IsZero() || time.Now().UTC().After(base) {
|
||||
base = time.Now().UTC()
|
||||
}
|
||||
newExpiry := base.Add(time.Duration(seconds) * time.Second)
|
||||
if app.config.BoxOwnerMaxTotalExpirySeconds > 0 && !record.CreatedAt.IsZero() && newExpiry.After(record.CreatedAt.Add(time.Duration(app.config.BoxOwnerMaxTotalExpirySeconds)*time.Second)) {
|
||||
return fmt.Errorf("bump expiry exceeds maximum total expiry")
|
||||
}
|
||||
manifest, err := boxstore.ReadManifest(record.ID)
|
||||
if err == nil {
|
||||
manifest.ExpiresAt = newExpiry
|
||||
_ = boxstore.WriteManifest(record.ID, manifest)
|
||||
}
|
||||
record.ExpiresAt = newExpiry
|
||||
record.RefreshCount++
|
||||
if err := app.store.UpsertBoxRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) visibleBoxRecords(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (metastore.BoxRecordPage, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminBoxesView {
|
||||
filters.Owner = actor.ID
|
||||
}
|
||||
return app.store.ListBoxRecords(filters, page)
|
||||
}
|
||||
|
||||
func (app *App) authorizedBoxRecords(ctx *gin.Context, actor metastore.User, ids []string) ([]metastore.BoxRecord, error) {
|
||||
ids = uniqueNonEmpty(ids)
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no boxes selected")
|
||||
}
|
||||
perms := currentAccountPermissions(ctx)
|
||||
records := make([]metastore.BoxRecord, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
record, ok, err := app.store.GetBoxRecord(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("box %s not found", id)
|
||||
}
|
||||
if !perms.AdminBoxesView && record.OwnerID != actor.ID {
|
||||
return nil, fmt.Errorf("permission denied")
|
||||
}
|
||||
if !perms.AdminBoxesView && !app.config.BoxOwnerEditEnabled {
|
||||
return nil, fmt.Errorf("box owner edit policy is disabled")
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (app *App) boxRowView(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord) BoxRowView {
|
||||
owner := record.OwnerUsername
|
||||
if owner == "" {
|
||||
owner = "guest"
|
||||
}
|
||||
return BoxRowView{
|
||||
ID: record.ID,
|
||||
Owner: owner,
|
||||
Status: boxStatus(record),
|
||||
FileCount: record.FileCount,
|
||||
Size: helpers.FormatBytes(record.TotalSize),
|
||||
CreatedAt: formatAdminTime(record.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(record.ExpiresAt),
|
||||
Flags: boxFlags(record),
|
||||
Policy: app.boxRecordPolicy(record),
|
||||
CanManage: currentAccountPermissions(ctx).AdminBoxesView || record.OwnerID == actor.ID,
|
||||
ManageURL: "/account/boxes/" + record.ID,
|
||||
OpenURL: "/box/" + record.ID,
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) indexBoxFromManifest(boxID string) {
|
||||
manifest, err := boxstore.ReadManifest(boxID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = app.store.UpsertBoxRecord(boxRecordFromManifest(boxID, manifest))
|
||||
}
|
||||
|
||||
func boxRecordFromManifest(boxID string, manifest models.BoxManifest) metastore.BoxRecord {
|
||||
total := int64(0)
|
||||
names := make([]string, 0, len(manifest.Files))
|
||||
for _, file := range manifest.Files {
|
||||
total += file.Size
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return metastore.BoxRecord{
|
||||
ID: boxID,
|
||||
OwnerID: manifest.OwnerID,
|
||||
OwnerUsername: manifest.OwnerUsername,
|
||||
FileNames: names,
|
||||
FileCount: len(manifest.Files),
|
||||
TotalSize: total,
|
||||
CreatedAt: manifest.CreatedAt,
|
||||
ExpiresAt: manifest.ExpiresAt,
|
||||
PasswordProtected: boxstore.IsPasswordProtected(manifest),
|
||||
OneTimeDownload: manifest.OneTimeDownload,
|
||||
DisableZip: manifest.DisableZip,
|
||||
}
|
||||
}
|
||||
|
||||
func boxFiltersFromRequest(ctx *gin.Context) metastore.BoxFilters {
|
||||
return metastore.BoxFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Owner: emptyAsAll(ctx.Query("owner")),
|
||||
Status: emptyAsAll(ctx.Query("status")),
|
||||
Flag: emptyAsAll(ctx.Query("flag")),
|
||||
Sort: emptyAsNewest(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func boxPageFromRequest(ctx *gin.Context) metastore.BoxPageRequest {
|
||||
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "25"))
|
||||
return metastore.BoxPageRequest{Page: page, PageSize: pageSize}
|
||||
}
|
||||
|
||||
func boxStatus(record metastore.BoxRecord) string {
|
||||
if boxExpired(record) {
|
||||
return "expired"
|
||||
}
|
||||
if record.ExpiresAt.IsZero() {
|
||||
return "pending"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func boxExpired(record metastore.BoxRecord) bool {
|
||||
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
|
||||
}
|
||||
|
||||
func boxFlags(record metastore.BoxRecord) string {
|
||||
flags := []string{}
|
||||
if record.PasswordProtected {
|
||||
flags = append(flags, "password")
|
||||
}
|
||||
if record.OneTimeDownload {
|
||||
flags = append(flags, "one-time")
|
||||
}
|
||||
if record.DisableZip {
|
||||
flags = append(flags, "zip disabled")
|
||||
}
|
||||
if boxExpired(record) {
|
||||
flags = append(flags, "expired")
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return "normal"
|
||||
}
|
||||
return strings.Join(flags, ", ")
|
||||
}
|
||||
|
||||
func (app *App) boxRecordPolicy(record metastore.BoxRecord) string {
|
||||
if record.OneTimeDownload {
|
||||
return "one-time: no refresh"
|
||||
}
|
||||
if !app.config.BoxOwnerEditEnabled {
|
||||
return "owner edits disabled"
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
return "editable, no refresh"
|
||||
}
|
||||
return fmt.Sprintf("editable, refresh %d/%d", record.RefreshCount, app.config.BoxOwnerMaxRefreshCount)
|
||||
}
|
||||
|
||||
func (app *App) boxPolicySummary() string {
|
||||
if !app.config.BoxOwnerEditEnabled {
|
||||
return "Owners cannot edit boxes by default."
|
||||
}
|
||||
if !app.config.BoxOwnerRefreshEnabled {
|
||||
return "Owners can edit boxes but cannot refresh expiry."
|
||||
}
|
||||
return fmt.Sprintf("Owners can edit and refresh up to %d times by %s.", app.config.BoxOwnerMaxRefreshCount, formatDurationForSettings(app.config.BoxOwnerMaxRefreshAmountSeconds))
|
||||
}
|
||||
|
||||
func boxPageURL(ctx *gin.Context, page int) string {
|
||||
query := ctx.Request.URL.Query()
|
||||
query.Set("page", strconv.Itoa(page))
|
||||
return "/account/boxes?" + query.Encode()
|
||||
}
|
||||
|
||||
func parsePositiveInt64Default(raw string, fallback int64) int64 {
|
||||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
220
lib/server/account_boxes_test.go
Normal file
220
lib/server/account_boxes_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/models"
|
||||
)
|
||||
|
||||
func TestAccountBoxesAdminListsBoxes(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createIndexedBox(t, app, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", "", 10, false)
|
||||
|
||||
response := getAccountBoxes(router, session, "/account/boxes")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected boxes page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") {
|
||||
t.Fatal("expected indexed box in admin list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesRegularUserSeesOnlyOwnBoxes(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("box-user", "box-user@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createIndexedBox(t, app, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", user.ID, user.Username, 10, false)
|
||||
createIndexedBox(t, app, "cccccccccccccccccccccccccccccccc", "other", "other", 20, false)
|
||||
|
||||
response := getAccountBoxes(router, session, "/account/boxes")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected boxes page, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
|
||||
t.Fatal("expected own box")
|
||||
}
|
||||
if strings.Contains(body, "cccccccccccccccccccccccccccccccc") {
|
||||
t.Fatal("did not expect other user's box")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesFiltersSortAndPagination(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
createIndexedBox(t, app, "11111111111111111111111111111111", "", "", 10, false)
|
||||
createIndexedBox(t, app, "22222222222222222222222222222222", "", "", 1000, true)
|
||||
createIndexedBox(t, app, "33333333333333333333333333333333", "", "", 500, false)
|
||||
|
||||
response := getAccountBoxes(router, session, "/account/boxes?flag=password&sort=largest&page_size=25")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected boxes page, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "22222222222222222222222222222222") {
|
||||
t.Fatal("expected password filtered box")
|
||||
}
|
||||
if strings.Contains(body, "11111111111111111111111111111111") {
|
||||
t.Fatal("did not expect unfiltered box")
|
||||
}
|
||||
|
||||
page, err := app.store.ListBoxRecords(metastore.BoxFilters{Sort: "largest"}, metastore.BoxPageRequest{Page: 1, PageSize: 25})
|
||||
if err != nil {
|
||||
t.Fatalf("ListBoxRecords returned error: %v", err)
|
||||
}
|
||||
if len(page.Rows) != 3 || page.Rows[0].ID != "22222222222222222222222222222222" {
|
||||
t.Fatalf("expected largest sort first, got %#v", page.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesBulkExpireAndDelete(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "dddddddddddddddddddddddddddddddd"
|
||||
createIndexedBox(t, app, id, "", "", 10, false)
|
||||
|
||||
values := url.Values{"box_ids": []string{id}}
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/expire", values)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected expire redirect, got %d", response.Code)
|
||||
}
|
||||
record, ok, err := app.store.GetBoxRecord(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
|
||||
}
|
||||
if record.ExpiresAt.After(time.Now().UTC()) {
|
||||
t.Fatal("expected box to be expired")
|
||||
}
|
||||
|
||||
response = postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", values)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected delete redirect, got %d", response.Code)
|
||||
}
|
||||
if _, ok, err := app.store.GetBoxRecord(id); err != nil || ok {
|
||||
t.Fatalf("expected deleted record, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected box directory deleted, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesBulkDeletePermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("box-limited", "box-limited@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
createIndexedBox(t, app, id, "other", "other", 10, false)
|
||||
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", url.Values{"box_ids": []string{id}})
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesBumpExpiryPolicyRejection(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
app.config.BoxOwnerRefreshEnabled = false
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
id := "ffffffffffffffffffffffffffffffff"
|
||||
createIndexedBox(t, app, id, "", "", 10, false)
|
||||
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/bump-expiry", url.Values{"box_ids": []string{id}, "bump_seconds": []string{"60"}})
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected policy rejection, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountBoxesDeleteLargest(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
small := "12345123451234512345123451234512"
|
||||
large := "99999999999999999999999999999999"
|
||||
createIndexedBox(t, app, small, "", "", 10, false)
|
||||
createIndexedBox(t, app, large, "", "", 1000, false)
|
||||
|
||||
response := postAccountBoxesForm(router, session, "/account/boxes/delete-largest", nil)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected delete-largest redirect, got %d", response.Code)
|
||||
}
|
||||
if _, ok, err := app.store.GetBoxRecord(large); err != nil || ok {
|
||||
t.Fatalf("expected largest deleted, ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createIndexedBox(t *testing.T, app *App, id string, ownerID string, ownerUsername string, size int64, password bool) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(boxstore.BoxPath(id), 0755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
filename := "file-" + id[:4] + ".txt"
|
||||
if err := os.WriteFile(filepath.Join(boxstore.BoxPath(id), filename), []byte(strings.Repeat("x", int(size))), 0644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
manifest := models.BoxManifest{
|
||||
OwnerID: ownerID,
|
||||
OwnerUsername: ownerUsername,
|
||||
Files: []models.BoxFile{{
|
||||
ID: "abcdabcdabcdabcd",
|
||||
Name: filename,
|
||||
Size: size,
|
||||
Status: models.FileStatusReady,
|
||||
}},
|
||||
CreatedAt: time.Now().UTC().Add(-time.Duration(size) * time.Second),
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
RetentionSecs: 3600,
|
||||
}
|
||||
if password {
|
||||
manifest.PasswordHash = "hash"
|
||||
manifest.AuthToken = "token"
|
||||
}
|
||||
if err := boxstore.WriteManifest(id, manifest); err != nil {
|
||||
t.Fatalf("WriteManifest returned error: %v", err)
|
||||
}
|
||||
if err := app.store.UpsertBoxRecord(boxRecordFromManifest(id, manifest)); err != nil {
|
||||
t.Fatalf("UpsertBoxRecord returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getAccountBoxes(router http.Handler, session metastore.Session, path string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postAccountBoxesForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
|
||||
if values == nil {
|
||||
values = url.Values{}
|
||||
}
|
||||
values.Set("csrf_token", session.CSRFToken)
|
||||
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
61
lib/server/account_nav.go
Normal file
61
lib/server/account_nav.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type AccountNavView struct {
|
||||
Username string
|
||||
IsAdmin bool
|
||||
ActiveSection string
|
||||
AlertCount int
|
||||
AlertSeverity string
|
||||
CanViewBoxes bool
|
||||
CanViewAlerts bool
|
||||
CanViewUsers bool
|
||||
CanViewAPIKeys bool
|
||||
CanViewSettings bool
|
||||
}
|
||||
|
||||
func (app *App) accountNavView(ctx *gin.Context, activeSection string) AccountNavView {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
isAdmin := perms.AdminAccess
|
||||
|
||||
return AccountNavView{
|
||||
Username: app.currentAdminUsername(ctx),
|
||||
IsAdmin: isAdmin,
|
||||
ActiveSection: activeSection,
|
||||
AlertSeverity: "ok",
|
||||
CanViewBoxes: true,
|
||||
CanViewAlerts: true,
|
||||
CanViewUsers: perms.AdminUsersManage,
|
||||
CanViewAPIKeys: true,
|
||||
CanViewSettings: perms.AdminSettingsManage,
|
||||
}
|
||||
}
|
||||
|
||||
func currentAccountPermissions(ctx *gin.Context) metastore.EffectivePermissions {
|
||||
value, ok := ctx.Get("adminPerms")
|
||||
if !ok {
|
||||
return metastore.EffectivePermissions{}
|
||||
}
|
||||
perms, ok := value.(metastore.EffectivePermissions)
|
||||
if !ok {
|
||||
return metastore.EffectivePermissions{}
|
||||
}
|
||||
return perms
|
||||
}
|
||||
|
||||
func normalizeAlertSeverity(severity string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(severity))
|
||||
switch normalized {
|
||||
case "danger", "warning", "info", "ok":
|
||||
return normalized
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
253
lib/server/account_pages.go
Normal file
253
lib/server/account_pages.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type AccountDashboardView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Stats AccountDashboardStats
|
||||
Statuses []accountStatusRow
|
||||
Alerts []accountAlertPreviewRow
|
||||
RecentBoxes []accountDashboardBoxRow
|
||||
RecentActivity []accountActivityRow
|
||||
ShowUsersStat bool
|
||||
CanManageBoxes bool
|
||||
CanManageUsers bool
|
||||
CanViewSettings bool
|
||||
HasAlertsPreview bool
|
||||
}
|
||||
|
||||
type AccountDashboardStats struct {
|
||||
ActiveBoxes int
|
||||
StorageUsedLabel string
|
||||
AlertCount int
|
||||
TotalUsers int
|
||||
ActiveUsers int
|
||||
DisabledUsers int
|
||||
}
|
||||
|
||||
type accountStatusRow struct {
|
||||
Label string
|
||||
Value string
|
||||
Severity string
|
||||
}
|
||||
|
||||
type accountAlertPreviewRow struct {
|
||||
Severity string
|
||||
Title string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type accountDashboardBoxRow struct {
|
||||
ID string
|
||||
FileCount int
|
||||
TotalSizeLabel string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Flags string
|
||||
CanManage bool
|
||||
}
|
||||
|
||||
type accountActivityRow struct {
|
||||
Time string
|
||||
Title string
|
||||
Meta string
|
||||
}
|
||||
|
||||
func (app *App) handleAccountDashboard(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
view, err := app.GetAccountDashboard(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load account dashboard")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_dashboard.html", view)
|
||||
}
|
||||
|
||||
func (app *App) GetAccountDashboard(ctx *gin.Context, actor metastore.User) (AccountDashboardView, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
nav := app.accountNavView(ctx, "dashboard")
|
||||
|
||||
totalSize := int64(0)
|
||||
activeBoxes := 0
|
||||
recentBoxes := []accountDashboardBoxRow{}
|
||||
if perms.AdminBoxesView {
|
||||
summaries, err := boxstore.ListBoxSummaries()
|
||||
if err != nil {
|
||||
return AccountDashboardView{}, err
|
||||
}
|
||||
|
||||
recentBoxes = make([]accountDashboardBoxRow, 0, minInt(len(summaries), 10))
|
||||
for _, summary := range summaries {
|
||||
totalSize += summary.TotalSize
|
||||
if !summary.Expired {
|
||||
activeBoxes++
|
||||
}
|
||||
if len(recentBoxes) < 10 {
|
||||
recentBoxes = append(recentBoxes, accountDashboardBoxRow{
|
||||
ID: summary.ID,
|
||||
FileCount: summary.FileCount,
|
||||
TotalSizeLabel: summary.TotalSizeLabel,
|
||||
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||
Flags: accountBoxFlags(summary.Expired, summary.OneTimeDownload, summary.PasswordProtected),
|
||||
CanManage: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats := AccountDashboardStats{
|
||||
ActiveBoxes: activeBoxes,
|
||||
StorageUsedLabel: helpers.FormatBytes(totalSize),
|
||||
}
|
||||
alertPreview := []accountAlertPreviewRow{}
|
||||
if perms.AdminAccess {
|
||||
stats.AlertCount, nav.AlertSeverity = app.openAlertSummary()
|
||||
nav.AlertCount = stats.AlertCount
|
||||
alertPreview = app.accountDashboardAlertPreview()
|
||||
}
|
||||
|
||||
showUsersStat := perms.AdminUsersManage
|
||||
if showUsersStat {
|
||||
users, err := app.store.ListUsers()
|
||||
if err != nil {
|
||||
return AccountDashboardView{}, err
|
||||
}
|
||||
stats.TotalUsers = len(users)
|
||||
for _, user := range users {
|
||||
if user.Disabled {
|
||||
stats.DisabledUsers++
|
||||
} else {
|
||||
stats.ActiveUsers++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AccountDashboardView{
|
||||
PageTitle: "WarpBox Account",
|
||||
WindowTitle: "WarpBox Account Control Panel",
|
||||
WindowIcon: "W",
|
||||
AccountNav: nav,
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Stats: stats,
|
||||
Statuses: app.accountDashboardStatuses(),
|
||||
Alerts: alertPreview,
|
||||
RecentBoxes: recentBoxes,
|
||||
RecentActivity: accountPlaceholderActivity(actor, ctx),
|
||||
ShowUsersStat: showUsersStat,
|
||||
CanManageBoxes: perms.AdminBoxesView,
|
||||
CanManageUsers: perms.AdminUsersManage,
|
||||
CanViewSettings: perms.AdminSettingsManage,
|
||||
HasAlertsPreview: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) accountDashboardStatuses() []accountStatusRow {
|
||||
return []accountStatusRow{
|
||||
{Label: "Guest uploads", Value: enabledLabel(app.config.GuestUploadsEnabled), Severity: boolSeverity(app.config.GuestUploadsEnabled)},
|
||||
{Label: "API", Value: enabledLabel(app.config.APIEnabled), Severity: boolSeverity(app.config.APIEnabled)},
|
||||
{Label: "ZIP downloads", Value: enabledLabel(app.config.ZipDownloadsEnabled), Severity: boolSeverity(app.config.ZipDownloadsEnabled)},
|
||||
{Label: "One-time boxes", Value: enabledLabel(app.config.OneTimeDownloadsEnabled), Severity: boolSeverity(app.config.OneTimeDownloadsEnabled)},
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) accountDashboardAlertPreview() []accountAlertPreviewRow {
|
||||
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen, Sort: "severity"})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rows := make([]accountAlertPreviewRow, 0, minInt(len(alerts), 6))
|
||||
for _, alert := range alerts {
|
||||
if len(rows) == 6 {
|
||||
break
|
||||
}
|
||||
rows = append(rows, accountAlertPreviewRow{
|
||||
Severity: alert.Severity,
|
||||
Title: alert.Title,
|
||||
Detail: alert.Description,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func accountPlaceholderActivity(actor metastore.User, ctx *gin.Context) []accountActivityRow {
|
||||
now := time.Now().UTC()
|
||||
if value, ok := ctx.Get("accountSession"); ok {
|
||||
if session, ok := value.(metastore.Session); ok {
|
||||
now = session.CreatedAt
|
||||
}
|
||||
}
|
||||
return []accountActivityRow{
|
||||
{
|
||||
Time: formatAdminTime(now),
|
||||
Title: "Signed in",
|
||||
Meta: actor.Username + " opened the account dashboard.",
|
||||
},
|
||||
{
|
||||
Time: "pending",
|
||||
Title: "Audit log not implemented",
|
||||
Meta: "Recent account activity will use the audit model in a later pass.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func accountBoxFlags(expired bool, oneTime bool, passwordProtected bool) string {
|
||||
flags := []string{}
|
||||
if expired {
|
||||
flags = append(flags, "expired")
|
||||
}
|
||||
if oneTime {
|
||||
flags = append(flags, "one-time")
|
||||
}
|
||||
if passwordProtected {
|
||||
flags = append(flags, "password")
|
||||
}
|
||||
if len(flags) == 0 {
|
||||
return "normal"
|
||||
}
|
||||
out := flags[0]
|
||||
for _, flag := range flags[1:] {
|
||||
out += ", " + flag
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func enabledLabel(enabled bool) string {
|
||||
if enabled {
|
||||
return "enabled"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
func boolSeverity(enabled bool) string {
|
||||
if enabled {
|
||||
return "ok"
|
||||
}
|
||||
return "warn"
|
||||
}
|
||||
|
||||
func minInt(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
506
lib/server/account_settings.go
Normal file
506
lib/server/account_settings.go
Normal file
@@ -0,0 +1,506 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type SettingsView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Groups []SettingsGroupView
|
||||
OverridesAllowed bool
|
||||
CanEdit bool
|
||||
Error string
|
||||
Notice string
|
||||
}
|
||||
|
||||
type SettingsGroupView struct {
|
||||
Key string
|
||||
Label string
|
||||
Description string
|
||||
Rows []SettingsRowView
|
||||
}
|
||||
|
||||
type SettingsRowView struct {
|
||||
Key string
|
||||
Label string
|
||||
Description string
|
||||
Type config.SettingType
|
||||
Value string
|
||||
DisplayValue string
|
||||
Source string
|
||||
EnvName string
|
||||
Editable bool
|
||||
LockedReason string
|
||||
Future bool
|
||||
}
|
||||
|
||||
type SettingsBackup struct {
|
||||
Version int `json:"version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type ImportResult struct {
|
||||
Applied int `json:"applied"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
type settingsMeta struct {
|
||||
Group string
|
||||
Description string
|
||||
Units string
|
||||
Future bool
|
||||
}
|
||||
|
||||
var settingsGroups = []SettingsGroupView{
|
||||
{Key: "uploads", Label: "Uploads", Description: "Guest uploads and upload size defaults."},
|
||||
{Key: "downloads", Label: "Downloads", Description: "ZIP and one-time download behavior."},
|
||||
{Key: "retention", Label: "Retention", Description: "Expiry and renewal defaults."},
|
||||
{Key: "accounts", Label: "Accounts", Description: "Session and account defaults."},
|
||||
{Key: "api", Label: "API", Description: "API surface toggles."},
|
||||
{Key: "storage", Label: "Storage", Description: "Storage paths and hard capacity limits."},
|
||||
{Key: "workers", Label: "Workers", Description: "Background worker timing."},
|
||||
{Key: "box_policy", Label: "Box policy", Description: "Defaults for future owner-managed boxes."},
|
||||
}
|
||||
|
||||
var settingsMetadata = map[string]settingsMeta{
|
||||
config.SettingGuestUploadsEnabled: {Group: "uploads", Description: "Allow guests to create upload boxes."},
|
||||
config.SettingDefaultUserMaxFileBytes: {Group: "uploads", Description: "Default per-user file size limit. Zero means unlimited.", Units: "bytes"},
|
||||
config.SettingDefaultUserMaxBoxBytes: {Group: "uploads", Description: "Default per-user total box size limit. Zero means unlimited.", Units: "bytes"},
|
||||
config.SettingZipDownloadsEnabled: {Group: "downloads", Description: "Allow ZIP downloads when a box permits it."},
|
||||
config.SettingOneTimeDownloadsEnabled: {Group: "downloads", Description: "Allow one-time ZIP handoff boxes."},
|
||||
config.SettingOneTimeDownloadExpirySecs: {Group: "downloads", Description: "How long one-time downloads stay retryable or pending.", Units: "duration"},
|
||||
config.SettingOneTimeDownloadRetryFail: {Group: "downloads", Description: "Keep one-time boxes retryable after a ZIP writer failure."},
|
||||
config.SettingDefaultGuestExpirySecs: {Group: "retention", Description: "Default guest box expiry.", Units: "duration"},
|
||||
config.SettingMaxGuestExpirySecs: {Group: "retention", Description: "Maximum guest box expiry.", Units: "duration"},
|
||||
config.SettingRenewOnAccessEnabled: {Group: "retention", Description: "Allow expiry renewal when a box is opened."},
|
||||
config.SettingRenewOnDownloadEnabled: {Group: "retention", Description: "Allow expiry renewal when files are downloaded."},
|
||||
config.SettingSessionTTLSeconds: {Group: "accounts", Description: "Account session lifetime.", Units: "duration"},
|
||||
config.SettingAPIEnabled: {Group: "api", Description: "Expose API-style upload/status endpoints."},
|
||||
config.SettingDataDir: {Group: "storage", Description: "Base data directory. Environment only."},
|
||||
config.SettingGlobalMaxFileSizeBytes: {Group: "storage", Description: "Hard global file size cap. Environment only.", Units: "bytes"},
|
||||
config.SettingGlobalMaxBoxSizeBytes: {Group: "storage", Description: "Hard global box size cap. Environment only.", Units: "bytes"},
|
||||
config.SettingBoxPollIntervalMS: {Group: "workers", Description: "Browser polling cadence for box status.", Units: "milliseconds"},
|
||||
config.SettingThumbnailBatchSize: {Group: "workers", Description: "Thumbnail worker batch size."},
|
||||
config.SettingThumbnailIntervalSeconds: {Group: "workers", Description: "Thumbnail worker interval.", Units: "duration"},
|
||||
config.SettingBoxOwnerEditEnabled: {Group: "box_policy", Description: "Default: owners may edit their boxes."},
|
||||
config.SettingBoxOwnerRefreshEnabled: {Group: "box_policy", Description: "Default: owners may refresh box expiry."},
|
||||
config.SettingBoxOwnerMaxRefreshCount: {Group: "box_policy", Description: "Default maximum number of owner refreshes."},
|
||||
config.SettingBoxOwnerMaxRefreshAmount: {Group: "box_policy", Description: "Default maximum expiry added per owner refresh.", Units: "duration"},
|
||||
config.SettingBoxOwnerMaxTotalExpiry: {Group: "box_policy", Description: "Default maximum total box expiry for owner-managed boxes.", Units: "duration"},
|
||||
config.SettingBoxOwnerPasswordEdit: {Group: "box_policy", Description: "Default: owners may edit box passwords."},
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettings(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
view, err := app.ListSettings(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_settings.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ctx.Request.ParseForm(); err != nil {
|
||||
app.renderSettingsWithMessage(ctx, actor, "could not parse settings form", "")
|
||||
return
|
||||
}
|
||||
|
||||
editable := map[string]config.SettingDefinition{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
editable[def.Key] = def
|
||||
}
|
||||
for key := range ctx.Request.PostForm {
|
||||
if key == "csrf_token" {
|
||||
continue
|
||||
}
|
||||
if _, ok := editable[key]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := config.Definition(key); ok {
|
||||
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("setting %q is locked", key), "")
|
||||
return
|
||||
}
|
||||
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("unknown setting %q", key), "")
|
||||
return
|
||||
}
|
||||
|
||||
changes := map[string]string{}
|
||||
for _, def := range editable {
|
||||
if def.Type == config.SettingTypeBool {
|
||||
value := "false"
|
||||
if ctx.PostForm(def.Key) == "true" {
|
||||
value = "true"
|
||||
}
|
||||
changes[def.Key] = value
|
||||
continue
|
||||
}
|
||||
if _, exists := ctx.GetPostForm(def.Key); exists {
|
||||
changes[def.Key] = ctx.PostForm(def.Key)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.UpdateSettings(ctx, actor, changes); err != nil {
|
||||
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/settings")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsReset(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if err := app.ResetSettingOverride(ctx, actor, ctx.PostForm("key")); err != nil {
|
||||
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
|
||||
return
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/settings")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsExport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
backup, err := app.ExportSettings(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
ctx.Header("Content-Disposition", `attachment; filename="warpbox-settings.json"`)
|
||||
ctx.JSON(http.StatusOK, backup)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountSettingsImport(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(ctx.GetHeader("Content-Type")), "application/json") {
|
||||
ctx.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "settings import requires application/json"})
|
||||
return
|
||||
}
|
||||
var backup SettingsBackup
|
||||
if err := json.NewDecoder(ctx.Request.Body).Decode(&backup); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings JSON"})
|
||||
return
|
||||
}
|
||||
result, err := app.ImportSettings(ctx, actor, backup)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (app *App) ListSettings(ctx *gin.Context, actor metastore.User) (SettingsView, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminSettingsManage {
|
||||
return SettingsView{}, fmt.Errorf("permission denied")
|
||||
}
|
||||
|
||||
rows := app.settingsRows(perms.AdminSettingsManage && app.config.AllowAdminSettingsOverride)
|
||||
groups := make([]SettingsGroupView, 0, len(settingsGroups))
|
||||
for _, group := range settingsGroups {
|
||||
copyGroup := group
|
||||
copyGroup.Rows = rows[group.Key]
|
||||
groups = append(groups, copyGroup)
|
||||
}
|
||||
|
||||
return SettingsView{
|
||||
PageTitle: "WarpBox Settings",
|
||||
WindowTitle: "WarpBox Account Settings",
|
||||
WindowIcon: "S",
|
||||
PageScripts: []string{"/static/js/account-settings.js"},
|
||||
AccountNav: app.accountNavView(ctx, "settings"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Groups: groups,
|
||||
OverridesAllowed: app.config.AllowAdminSettingsOverride,
|
||||
CanEdit: app.config.AllowAdminSettingsOverride,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) UpdateSettings(ctx *gin.Context, actor metastore.User, changes map[string]string) error {
|
||||
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
return fmt.Errorf("admin settings overrides are disabled")
|
||||
}
|
||||
if err := validateSettingChanges(changes); err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range changes {
|
||||
if err := app.store.SetSetting(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return app.reloadRuntimeConfig()
|
||||
}
|
||||
|
||||
func (app *App) ResetSettingOverride(ctx *gin.Context, actor metastore.User, key string) error {
|
||||
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
def, ok := config.Definition(strings.TrimSpace(key))
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown setting %q", key)
|
||||
}
|
||||
if !def.Editable || def.HardLimit {
|
||||
return fmt.Errorf("setting %q cannot be reset from account settings", key)
|
||||
}
|
||||
if err := app.store.DeleteSetting(def.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
return app.reloadRuntimeConfig()
|
||||
}
|
||||
|
||||
func (app *App) ExportSettings(ctx *gin.Context, actor metastore.User) (SettingsBackup, error) {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminSettingsManage {
|
||||
return SettingsBackup{}, fmt.Errorf("permission denied")
|
||||
}
|
||||
settings := map[string]string{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
settings[def.Key] = app.config.SettingValue(def.Key)
|
||||
}
|
||||
return SettingsBackup{
|
||||
Version: 1,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Settings: settings,
|
||||
Metadata: map[string]string{
|
||||
"app": "WarpBox",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) ImportSettings(ctx *gin.Context, actor metastore.User, backup SettingsBackup) (ImportResult, error) {
|
||||
if err := app.requireSettingsEdit(ctx); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
return ImportResult{}, fmt.Errorf("admin settings overrides are disabled")
|
||||
}
|
||||
if backup.Settings == nil {
|
||||
return ImportResult{}, fmt.Errorf("settings backup has no settings")
|
||||
}
|
||||
if err := validateSettingChanges(backup.Settings); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(backup.Settings))
|
||||
for key := range backup.Settings {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if err := app.store.SetSetting(key, backup.Settings[key]); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
}
|
||||
if err := app.reloadRuntimeConfig(); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return ImportResult{Applied: len(keys), Keys: keys}, nil
|
||||
}
|
||||
|
||||
func (app *App) renderSettingsWithMessage(ctx *gin.Context, actor metastore.User, errorMessage string, notice string) {
|
||||
view, err := app.ListSettings(ctx, actor)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
view.Error = errorMessage
|
||||
view.Notice = notice
|
||||
ctx.HTML(http.StatusOK, "account_settings.html", view)
|
||||
}
|
||||
|
||||
func (app *App) requireSettingsEdit(ctx *gin.Context) error {
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminSettingsManage {
|
||||
return fmt.Errorf("permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) settingsRows(canEdit bool) map[string][]SettingsRowView {
|
||||
out := map[string][]SettingsRowView{}
|
||||
for _, row := range app.config.SettingRows() {
|
||||
meta := settingsMetadata[row.Definition.Key]
|
||||
group := meta.Group
|
||||
if group == "" {
|
||||
group = "accounts"
|
||||
}
|
||||
editable := canEdit && row.Definition.Editable && !row.Definition.HardLimit
|
||||
out[group] = append(out[group], SettingsRowView{
|
||||
Key: row.Definition.Key,
|
||||
Label: row.Definition.Label,
|
||||
Description: meta.Description,
|
||||
Type: row.Definition.Type,
|
||||
Value: row.Value,
|
||||
DisplayValue: settingDisplayValue(row.Value, meta.Units),
|
||||
Source: settingSourceLabel(row.Source, row.Definition),
|
||||
EnvName: row.Definition.EnvName,
|
||||
Editable: editable,
|
||||
LockedReason: settingLockedReason(row.Definition, canEdit),
|
||||
Future: meta.Future,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateSettingChanges(changes map[string]string) error {
|
||||
if len(changes) == 0 {
|
||||
return fmt.Errorf("no settings provided")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range changes {
|
||||
if _, ok := config.Definition(key); !ok {
|
||||
return fmt.Errorf("unknown setting %q", key)
|
||||
}
|
||||
if err := cfg.ApplyOverride(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) reloadRuntimeConfig() error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overrides, err := app.store.ListSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
app.config = cfg
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func settingSourceLabel(source config.Source, def config.SettingDefinition) string {
|
||||
if def.HardLimit {
|
||||
return "hard env"
|
||||
}
|
||||
if !def.Editable {
|
||||
return "locked"
|
||||
}
|
||||
switch source {
|
||||
case config.SourceDB:
|
||||
return "override"
|
||||
case config.SourceEnv:
|
||||
return "env"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func settingLockedReason(def config.SettingDefinition, canEdit bool) string {
|
||||
if !canEdit {
|
||||
return "settings changes disabled"
|
||||
}
|
||||
if def.HardLimit {
|
||||
return "hard environment limit"
|
||||
}
|
||||
if !def.Editable {
|
||||
return "runtime editing not supported"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func settingDisplayValue(value string, units string) string {
|
||||
switch units {
|
||||
case "bytes":
|
||||
parsed, ok := parseInt64String(value)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
if parsed == 0 {
|
||||
return "unlimited"
|
||||
}
|
||||
return fmt.Sprintf("%s (%s bytes)", formatBytesForSettings(parsed), value)
|
||||
case "duration":
|
||||
parsed, ok := parseInt64String(value)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
return fmt.Sprintf("%s (%s seconds)", formatDurationForSettings(parsed), value)
|
||||
case "milliseconds":
|
||||
return value + " ms"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt64String(value string) (int64, bool) {
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscan(strings.TrimSpace(value), &parsed); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func formatBytesForSettings(value int64) string {
|
||||
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
|
||||
size := float64(value)
|
||||
unit := 0
|
||||
for size >= 1024 && unit < len(units)-1 {
|
||||
size /= 1024
|
||||
unit++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", size, units[unit])
|
||||
}
|
||||
|
||||
func formatDurationForSettings(seconds int64) string {
|
||||
switch {
|
||||
case seconds == 0:
|
||||
return "none"
|
||||
case seconds%86400 == 0:
|
||||
return fmt.Sprintf("%d days", seconds/86400)
|
||||
case seconds%3600 == 0:
|
||||
return fmt.Sprintf("%d hours", seconds/3600)
|
||||
case seconds%60 == 0:
|
||||
return fmt.Sprintf("%d minutes", seconds/60)
|
||||
default:
|
||||
return fmt.Sprintf("%d seconds", seconds)
|
||||
}
|
||||
}
|
||||
197
lib/server/account_settings_test.go
Normal file
197
lib/server/account_settings_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountSettingsPermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("regular", "regular@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsPageLoadsForAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected settings page, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, text := range []string{"Uploads", "Downloads", "Box policy", "Save Settings"} {
|
||||
if !strings.Contains(response.Body.String(), text) {
|
||||
t.Fatalf("expected settings page to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsValidUpdate(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set(config.SettingAPIEnabled, "false")
|
||||
response := postAccountSettingsForm(router, session, form)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected settings redirect, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if app.config.APIEnabled {
|
||||
t.Fatal("expected API setting to be disabled")
|
||||
}
|
||||
value, ok, err := app.store.GetSetting(config.SettingAPIEnabled)
|
||||
if err != nil || !ok || value != "false" {
|
||||
t.Fatalf("expected API setting override false, got value=%q ok=%v err=%v", value, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsInvalidUpdate(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set(config.SettingSessionTTLSeconds, "1")
|
||||
response := postAccountSettingsForm(router, session, form)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected settings form render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "must be at least 60") {
|
||||
t.Fatal("expected validation error in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsLockedSettingCannotChange(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set(config.SettingGlobalMaxFileSizeBytes, "1")
|
||||
response := postAccountSettingsForm(router, session, form)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected settings form render, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "locked") {
|
||||
t.Fatal("expected locked setting error")
|
||||
}
|
||||
if value, ok, err := app.store.GetSetting(config.SettingGlobalMaxFileSizeBytes); err != nil || ok || value != "" {
|
||||
t.Fatalf("expected no locked setting override, got value=%q ok=%v err=%v", value, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsImportRejectsUnknownOrInvalidSettings(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
for _, body := range []string{
|
||||
`{"version":1,"settings":{"not_real":"true"}}`,
|
||||
`{"version":1,"settings":{"session_ttl_seconds":"1"}}`,
|
||||
} {
|
||||
response := postAccountSettingsJSON(router, session, body)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected bad import for %s, got %d", body, response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsImportAppliesValidSettings(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
response := postAccountSettingsJSON(router, session, `{"version":1,"settings":{"api_enabled":"false","box_owner_max_refresh_count":"7"}}`)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected import success, got %d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if app.config.APIEnabled {
|
||||
t.Fatal("expected imported API setting to be disabled")
|
||||
}
|
||||
if app.config.BoxOwnerMaxRefreshCount != 7 {
|
||||
t.Fatalf("expected imported box owner refresh count 7, got %d", app.config.BoxOwnerMaxRefreshCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountSettingsExportShape(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session := createAccountTestSession(t, app, user)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/settings/export.json", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected export success, got %d", response.Code)
|
||||
}
|
||||
var backup SettingsBackup
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &backup); err != nil {
|
||||
t.Fatalf("Unmarshal returned error: %v", err)
|
||||
}
|
||||
if backup.Version != 1 {
|
||||
t.Fatalf("expected version 1, got %d", backup.Version)
|
||||
}
|
||||
if _, ok := backup.Settings[config.SettingBoxOwnerMaxRefreshCount]; !ok {
|
||||
t.Fatal("expected export to include box owner policy setting")
|
||||
}
|
||||
if _, ok := backup.Settings[config.SettingDataDir]; ok {
|
||||
t.Fatal("did not expect locked data dir in export settings")
|
||||
}
|
||||
}
|
||||
|
||||
func createAccountTestSession(t *testing.T, app *App, user metastore.User) metastore.Session {
|
||||
t.Helper()
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
func postAccountSettingsForm(router http.Handler, session metastore.Session, form url.Values) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/settings", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func postAccountSettingsJSON(router http.Handler, session metastore.Session, body string) *httptest.ResponseRecorder {
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/settings/import.json", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-CSRF-Token", session.CSRFToken)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
858
lib/server/account_test.go
Normal file
858
lib/server/account_test.go
Normal file
@@ -0,0 +1,858 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func TestAccountLoginSuccess(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
response := postAccountLogin(router, "admin", "secret")
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login redirect, got %d", response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != "/account" {
|
||||
t.Fatalf("expected redirect to /account, got %q", location)
|
||||
}
|
||||
if cookie := findResponseCookie(response, accountSessionCookie); cookie == nil || cookie.Value == "" {
|
||||
t.Fatal("expected account session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLoginFailure(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
response := postAccountLogin(router, "admin", "wrong")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected failed login to render form, got %d", response.Code)
|
||||
}
|
||||
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
|
||||
t.Fatal("did not expect account session cookie")
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "not accepted") {
|
||||
t.Fatal("expected login failure message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDisabledUserLoginFailure(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
user.Disabled = true
|
||||
if err := app.store.UpdateUser(user); err != nil {
|
||||
t.Fatalf("UpdateUser returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
response := postAccountLogin(router, "admin", "secret")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected disabled login to render form, got %d", response.Code)
|
||||
}
|
||||
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
|
||||
t.Fatal("did not expect account session cookie")
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "not accepted") {
|
||||
t.Fatal("expected login failure message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountLogoutRequiresCSRF(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/logout", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDashboardRequiresAuth(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected dashboard redirect, got %d", response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != "/account/login" {
|
||||
t.Fatalf("expected redirect to /account/login, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDashboardLoadsForBootstrapAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"Dashboard", "Recent Boxes", "Users"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected dashboard body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountDashboardHidesAdminOnlyLinksForRegularUser(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("maya", "maya@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected dashboard to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{">Users<", ">Settings<"} {
|
||||
if strings.Contains(body, text) {
|
||||
t.Fatalf("expected dashboard body to hide %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEntryRedirectsToAccount(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
|
||||
cases := map[string]string{
|
||||
"/admin/login": "/account/login",
|
||||
"/admin": "/account",
|
||||
}
|
||||
for path, wantLocation := range cases {
|
||||
request := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected %s redirect, got %d", path, response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != wantLocation {
|
||||
t.Fatalf("expected %s to redirect to %s, got %q", path, wantLocation, location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupAccountTestApp(t *testing.T) (*App, metastore.User) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
restoreUploadRoot := boxstore.UploadRoot()
|
||||
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
|
||||
boxstore.SetUploadRoot(t.TempDir())
|
||||
|
||||
store, err := metastore.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Open returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
cfg.AdminUsername = "admin"
|
||||
cfg.AdminPassword = "secret"
|
||||
cfg.AdminEmail = "admin@example.test"
|
||||
cfg.AdminEnabled = config.AdminEnabledAuto
|
||||
cfg.SessionTTLSeconds = 3600
|
||||
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
|
||||
if err != nil {
|
||||
t.Fatalf("BootstrapAdmin returned error: %v", err)
|
||||
}
|
||||
if bootstrap.AdminUser == nil {
|
||||
t.Fatal("expected bootstrap admin user")
|
||||
}
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
store: store,
|
||||
adminLoginEnabled: bootstrap.AdminLoginEnabled,
|
||||
}
|
||||
return app, *bootstrap.AdminUser
|
||||
}
|
||||
|
||||
func setupAccountTestRouter(t *testing.T, app *App) *gin.Engine {
|
||||
t.Helper()
|
||||
router := gin.New()
|
||||
templates, err := template.ParseGlob(filepath.Join("..", "..", "templates", "*.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseGlob returned error: %v", err)
|
||||
}
|
||||
router.SetHTMLTemplate(templates)
|
||||
app.registerAccountRoutes(router)
|
||||
app.registerAdminRoutes(router)
|
||||
return router
|
||||
}
|
||||
|
||||
func postAccountLogin(router *gin.Engine, username string, password string) *httptest.ResponseRecorder {
|
||||
form := url.Values{}
|
||||
form.Set("username", username)
|
||||
form.Set("password", password)
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/login", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func findResponseCookie(response *httptest.ResponseRecorder, name string) *http.Cookie {
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUsersPagePermissionDeniedForNoPerms(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
user, err := app.store.CreateUserWithPassword("viewer", "viewer@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected permission denied, got %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "Permission denied") {
|
||||
t.Fatal("expected permission denied message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPageLoadsForAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"WarpBox Users", "Create or Invite", "Total users"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected users page body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPageListFilters(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
_, err := app.store.CreateUserWithPassword("beta", "beta@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users?q=beta", nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected users page to load, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "beta") {
|
||||
t.Fatal("expected filtered list to contain beta")
|
||||
}
|
||||
if !strings.Contains(body, "1 matching user(s)") {
|
||||
t.Fatalf("expected 1 matching user for beta filter, got body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCreation(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("action", "create")
|
||||
form.Set("mode", "create")
|
||||
form.Set("username", "newuser")
|
||||
form.Set("email", "new@example.test")
|
||||
form.Set("password", "password123")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect after create, got %d", response.Code)
|
||||
}
|
||||
|
||||
created, ok, err := app.store.GetUserByUsername("newuser")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected newuser to exist")
|
||||
}
|
||||
if created.Disabled {
|
||||
t.Fatal("expected newuser to be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInviteCreation(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("action", "create")
|
||||
form.Set("mode", "invite")
|
||||
form.Set("username", "invited")
|
||||
form.Set("email", "invited@example.test")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect after invite, got %d", response.Code)
|
||||
}
|
||||
|
||||
created, ok, err := app.store.GetUserByUsername("invited")
|
||||
if err != nil || !ok {
|
||||
t.Fatal("expected invited user to exist")
|
||||
}
|
||||
if !created.Disabled {
|
||||
t.Fatal("expected invited user to be disabled")
|
||||
}
|
||||
if !strings.HasPrefix(created.PasswordHash, "invite/") {
|
||||
t.Fatal("expected invited user to have invite prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDisableRejectsSelf(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", user.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "cannot disable yourself") && !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected self-disable rejection, got location %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDisableProtectsFinalAdmin(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok || adminTag.ID == "" {
|
||||
t.Fatal("expected admin tag")
|
||||
}
|
||||
second, err := app.store.CreateUserWithPassword("admin2", "admin2@example.test", "secret", []string{adminTag.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
|
||||
// Admin tries to disable the other admin (not self): should work since self remains.
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", second.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected success redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "user(s) disabled") {
|
||||
t.Fatalf("expected success message, got %q", location)
|
||||
}
|
||||
|
||||
// Verify admin2 is disabled, admin1 still active
|
||||
disabledUser, ok, _ := app.store.GetUserByUsername("admin2")
|
||||
if !ok || !disabledUser.Disabled {
|
||||
t.Fatal("expected admin2 to be disabled")
|
||||
}
|
||||
adminUser, ok, _ := app.store.GetUserByUsername("admin")
|
||||
if !ok || adminUser.Disabled {
|
||||
t.Fatal("expected admin to remain active")
|
||||
}
|
||||
|
||||
// Now try to disable the only remaining admin (self): should be rejected
|
||||
form2 := url.Values{}
|
||||
form2.Set("csrf_token", session.CSRFToken)
|
||||
form2.Set("selected_ids", user.ID)
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/account/users/bulk/disable", strings.NewReader(form2.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
resp2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp2, req2)
|
||||
|
||||
if resp2.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect for self-disable rejection, got %d", resp2.Code)
|
||||
}
|
||||
loc2 := resp2.Header().Get("Location")
|
||||
if !strings.Contains(loc2, "cannot disable yourself") {
|
||||
t.Fatalf("expected self-disable rejection, got %q", loc2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPagePermissionDenied(t *testing.T) {
|
||||
app, _ := setupAccountTestApp(t)
|
||||
regular, err := app.store.CreateUserWithPassword("viewer2", "viewer2@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(regular.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users/"+regular.ID, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPageLoadsForAdmin(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("edittarget", "edittarget@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/account/users/"+target.ID, nil)
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, text := range []string{"edittarget", "Access rights", "Limits", "Setting overrides", "Resolved policy"} {
|
||||
if !strings.Contains(body, text) {
|
||||
t.Fatalf("expected body to contain %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditProfileUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("origname", "orig@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", "newname")
|
||||
form.Set("email", "new@example.test")
|
||||
form.Set("admin_note", "test note")
|
||||
form.Set("state", "active")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok {
|
||||
t.Fatal("user not found after update")
|
||||
}
|
||||
if updated.Username != "newname" {
|
||||
t.Fatalf("expected username newname, got %q", updated.Username)
|
||||
}
|
||||
if updated.AdminNote != "test note" {
|
||||
t.Fatalf("expected admin note, got %q", updated.AdminNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditAccessRightsUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("perm_target", "perm@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("upload_allowed", "1")
|
||||
form.Set("zip_download_allowed", "1")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok || updated.PermOverrides == nil {
|
||||
t.Fatal("expected perm overrides to be set")
|
||||
}
|
||||
if updated.PermOverrides.UploadAllowed == nil || !*updated.PermOverrides.UploadAllowed {
|
||||
t.Fatal("expected upload_allowed=true")
|
||||
}
|
||||
if updated.PermOverrides.ZipDownloadAllowed == nil || !*updated.PermOverrides.ZipDownloadAllowed {
|
||||
t.Fatal("expected zip_download_allowed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditLimitsUpdate(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("limits_target", "limits@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("max_file_size_bytes", "1073741824")
|
||||
form.Set("max_expiry_seconds", "86400")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
updated, ok, _ := app.store.GetUser(target.ID)
|
||||
if !ok {
|
||||
t.Fatal("user not found")
|
||||
}
|
||||
if updated.MaxFileSizeBytes == nil || *updated.MaxFileSizeBytes != 1073741824 {
|
||||
t.Fatalf("expected max_file_size_bytes=1073741824, got %v", updated.MaxFileSizeBytes)
|
||||
}
|
||||
if updated.MaxExpirySeconds == nil || *updated.MaxExpirySeconds != 86400 {
|
||||
t.Fatalf("expected max_expiry_seconds=86400, got %v", updated.MaxExpirySeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditInvalidLimitRejected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("badlimit", "badlimit@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", target.Username)
|
||||
form.Set("email", target.Email)
|
||||
form.Set("max_file_size_bytes", "notanumber")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error redirect, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditSelfDisableRejected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", admin.Username)
|
||||
form.Set("email", admin.Email)
|
||||
form.Set("state", "disabled")
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error redirect, got %q", location)
|
||||
}
|
||||
|
||||
unchanged, _, _ := app.store.GetUser(admin.ID)
|
||||
if unchanged.Disabled {
|
||||
t.Fatal("admin should not have been disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditLastAdminProtected(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
// try to remove admin tag from the only admin via is_admin=0 (unchecked)
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("username", admin.Username)
|
||||
form.Set("email", admin.Email)
|
||||
// is_admin NOT set → wantsAdmin=false → should be blocked
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+admin.ID, strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "error=") {
|
||||
t.Fatalf("expected error for last-admin removal, got %q", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditPasswordReset(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("resetme", "resetme@example.test", "oldpass", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/password/reset", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "success=") {
|
||||
t.Fatalf("expected success redirect, got %q", location)
|
||||
}
|
||||
|
||||
updated, _, _ := app.store.GetUser(target.ID)
|
||||
if metastore.VerifyPassword(updated.PasswordHash, "oldpass") {
|
||||
t.Fatal("old password should no longer work after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEditRevokeSessions(t *testing.T) {
|
||||
app, admin := setupAccountTestApp(t)
|
||||
target, err := app.store.CreateUserWithPassword("revokeme", "revokeme@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword: %v", err)
|
||||
}
|
||||
targetSession, err := app.store.CreateSession(target.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
router := setupAccountTestRouter(t, app)
|
||||
adminSession, err := app.store.CreateSession(admin.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", adminSession.CSRFToken)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/"+target.ID+"/sessions/revoke", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: adminSession.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
|
||||
_, stillValid, _ := app.store.GetSession(targetSession.Token)
|
||||
if stillValid {
|
||||
t.Fatal("target session should have been revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkRevokeSessions(t *testing.T) {
|
||||
app, user := setupAccountTestApp(t)
|
||||
router := setupAccountTestRouter(t, app)
|
||||
session, err := app.store.CreateSession(user.ID, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
other, err := app.store.CreateUserWithPassword("other", "other@example.test", "secret", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUserWithPassword returned error: %v", err)
|
||||
}
|
||||
if _, err := app.store.CreateSession(other.ID, time.Hour); err != nil {
|
||||
t.Fatalf("CreateSession returned error: %v", err)
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", session.CSRFToken)
|
||||
form.Set("selected_ids", other.ID)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/account/users/bulk/revoke-sessions", strings.NewReader(form.Encode()))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "Sessions revoked") {
|
||||
t.Fatalf("expected success message, got %q", location)
|
||||
}
|
||||
}
|
||||
557
lib/server/account_user_edit.go
Normal file
557
lib/server/account_user_edit.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
type UserEditView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Target metastore.User
|
||||
Tags []metastore.Tag
|
||||
AdminTagID string
|
||||
IsAdmin bool
|
||||
IsPending bool
|
||||
Status string
|
||||
Perms metastore.EffectivePermissions
|
||||
PolicyJSON string
|
||||
CanManage bool
|
||||
IsSelf bool
|
||||
Error string
|
||||
Success string
|
||||
// precomputed display values
|
||||
TagNames string
|
||||
CreatedAtStr string
|
||||
UpdatedAtStr string
|
||||
MaxFileSizeStr string
|
||||
MaxBoxSizeStr string
|
||||
MaxExpiryStr string
|
||||
// precomputed perm override checkbox states
|
||||
Check map[string]bool
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEdit(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
view, err := app.buildUserEditView(ctx, actor, userID, perms.AdminUsersManage, "", "")
|
||||
if err != nil {
|
||||
ctx.String(http.StatusNotFound, "User not found")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "account_user_edit.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEditPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
// profile
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||
adminNote := strings.TrimSpace(ctx.PostForm("admin_note"))
|
||||
if username == "" {
|
||||
redirectUserEdit(ctx, userID, "Username is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
// state (cannot change pending via this field)
|
||||
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||
if !isPending {
|
||||
stateVal := ctx.PostForm("state")
|
||||
switch stateVal {
|
||||
case "disabled":
|
||||
if target.ID == actor.ID {
|
||||
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
if !target.Disabled {
|
||||
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||
return
|
||||
}
|
||||
}
|
||||
target.Disabled = true
|
||||
case "active":
|
||||
target.Disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// admin tag toggle
|
||||
adminTag, adminTagOK, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not verify admin tag.", "")
|
||||
return
|
||||
}
|
||||
wantsAdmin := ctx.PostForm("is_admin") == "1"
|
||||
if adminTagOK {
|
||||
hasAdmin := containsString(target.TagIDs, adminTag.ID)
|
||||
if wantsAdmin && !hasAdmin {
|
||||
target.TagIDs = append(target.TagIDs, adminTag.ID)
|
||||
} else if !wantsAdmin && hasAdmin {
|
||||
if err := app.checkLastAdminDisable([]string{target.ID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Cannot remove admin from the last active administrator.", "")
|
||||
return
|
||||
}
|
||||
target.TagIDs = removeString(target.TagIDs, adminTag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// per-user permission overrides
|
||||
target.PermOverrides = &metastore.UserPermOverrides{
|
||||
UploadAllowed: boolPtr(ctx.PostForm("upload_allowed") == "1"),
|
||||
ManageOwnBoxes: boolPtr(ctx.PostForm("manage_own_boxes") == "1"),
|
||||
ZipDownloadAllowed: boolPtr(ctx.PostForm("zip_download_allowed") == "1"),
|
||||
OneTimeDownloadAllowed: boolPtr(ctx.PostForm("one_time_download_allowed") == "1"),
|
||||
RenewableAllowed: boolPtr(ctx.PostForm("renewable_allowed") == "1"),
|
||||
AllowPasswordProtected: boolPtr(ctx.PostForm("allow_password_protected") == "1"),
|
||||
RenewOnAccess: boolPtr(ctx.PostForm("renew_on_access") == "1"),
|
||||
RenewOnDownload: boolPtr(ctx.PostForm("renew_on_download") == "1"),
|
||||
AllowOwnerBoxEditing: boolPtr(ctx.PostForm("allow_owner_box_editing") == "1"),
|
||||
}
|
||||
|
||||
// limits
|
||||
if raw := ctx.PostForm("max_file_size_bytes"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max file size: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxFileSizeBytes = v
|
||||
} else {
|
||||
target.MaxFileSizeBytes = nil
|
||||
}
|
||||
if raw := ctx.PostForm("max_box_size_bytes"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max box size: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxBoxSizeBytes = v
|
||||
} else {
|
||||
target.MaxBoxSizeBytes = nil
|
||||
}
|
||||
if raw := ctx.PostForm("max_expiry_seconds"); raw != "" {
|
||||
v, err := parseOptionalInt64(raw)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Max expiry: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
target.MaxExpirySeconds = v
|
||||
} else {
|
||||
target.MaxExpirySeconds = nil
|
||||
}
|
||||
|
||||
target.Username = username
|
||||
target.Email = email
|
||||
target.AdminNote = adminNote
|
||||
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not save user: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "User saved.")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserDisable(ctx *gin.Context) {
|
||||
app.handleAccountUserSetDisabled(ctx, true)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserEnable(ctx *gin.Context) {
|
||||
app.handleAccountUserSetDisabled(ctx, false)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserSetDisabled(ctx *gin.Context, disabled bool) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if userID == actor.ID && disabled {
|
||||
redirectUserEdit(ctx, userID, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if disabled {
|
||||
if err := app.checkLastAdminDisable([]string{userID}); err != nil {
|
||||
redirectUserEdit(ctx, userID, err.Error(), "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
target.Disabled = disabled
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not update user.", "")
|
||||
return
|
||||
}
|
||||
action := "enabled"
|
||||
if disabled {
|
||||
action = "disabled"
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "User "+action+".")
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserPasswordReset(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
redirectUserEdit(ctx, userID, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
newPassword := randomPassword()
|
||||
hash, err := metastore.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not hash password.", "")
|
||||
return
|
||||
}
|
||||
target.PasswordHash = hash
|
||||
target.Disabled = false
|
||||
if err := app.store.UpdateUser(target); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not reset password.", "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "Password reset. Temporary password: "+newPassword)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUserRevokeSessions(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if err := app.store.RevokeUserSessions(userID); err != nil {
|
||||
redirectUserEdit(ctx, userID, "Could not revoke sessions.", "")
|
||||
return
|
||||
}
|
||||
redirectUserEdit(ctx, userID, "", "All sessions revoked.")
|
||||
}
|
||||
|
||||
func (app *App) buildUserEditView(ctx *gin.Context, actor metastore.User, userID string, canManage bool, errMsg string, successMsg string) (UserEditView, error) {
|
||||
target, found, err := app.store.GetUser(userID)
|
||||
if err != nil || !found {
|
||||
return UserEditView{}, err
|
||||
}
|
||||
|
||||
tags, _ := app.store.ListTags()
|
||||
|
||||
adminTagID := ""
|
||||
for _, t := range tags {
|
||||
if t.Name == metastore.AdminTagName {
|
||||
adminTagID = t.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isAdmin := containsString(target.TagIDs, adminTagID)
|
||||
isPending := strings.HasPrefix(target.PasswordHash, "invite/")
|
||||
status := "active"
|
||||
if isPending {
|
||||
status = "pending"
|
||||
} else if target.Disabled {
|
||||
status = "disabled"
|
||||
}
|
||||
|
||||
effectivePerms, _ := app.permissionsForUser(target)
|
||||
|
||||
policyJSON := buildPolicyJSON(target.Username, status, effectivePerms, target.PermOverrides)
|
||||
|
||||
// tag names
|
||||
tagNames := make([]string, 0, len(target.TagIDs))
|
||||
for _, tagID := range target.TagIDs {
|
||||
for _, t := range tags {
|
||||
if t.ID == tagID {
|
||||
tagNames = append(tagNames, t.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// perm override checkboxes
|
||||
checks := map[string]bool{
|
||||
"upload_allowed": effectivePerms.UploadAllowed,
|
||||
"manage_own_boxes": false,
|
||||
"zip_download_allowed": effectivePerms.ZipDownloadAllowed,
|
||||
"one_time_download_allowed": effectivePerms.OneTimeDownloadAllowed,
|
||||
"renewable_allowed": effectivePerms.RenewableAllowed,
|
||||
"allow_password_protected": false,
|
||||
"renew_on_access": false,
|
||||
"renew_on_download": false,
|
||||
"allow_owner_box_editing": false,
|
||||
}
|
||||
if o := target.PermOverrides; o != nil {
|
||||
if o.UploadAllowed != nil {
|
||||
checks["upload_allowed"] = *o.UploadAllowed
|
||||
}
|
||||
if o.ManageOwnBoxes != nil {
|
||||
checks["manage_own_boxes"] = *o.ManageOwnBoxes
|
||||
}
|
||||
if o.ZipDownloadAllowed != nil {
|
||||
checks["zip_download_allowed"] = *o.ZipDownloadAllowed
|
||||
}
|
||||
if o.OneTimeDownloadAllowed != nil {
|
||||
checks["one_time_download_allowed"] = *o.OneTimeDownloadAllowed
|
||||
}
|
||||
if o.RenewableAllowed != nil {
|
||||
checks["renewable_allowed"] = *o.RenewableAllowed
|
||||
}
|
||||
if o.AllowPasswordProtected != nil {
|
||||
checks["allow_password_protected"] = *o.AllowPasswordProtected
|
||||
}
|
||||
if o.RenewOnAccess != nil {
|
||||
checks["renew_on_access"] = *o.RenewOnAccess
|
||||
}
|
||||
if o.RenewOnDownload != nil {
|
||||
checks["renew_on_download"] = *o.RenewOnDownload
|
||||
}
|
||||
if o.AllowOwnerBoxEditing != nil {
|
||||
checks["allow_owner_box_editing"] = *o.AllowOwnerBoxEditing
|
||||
}
|
||||
}
|
||||
|
||||
return UserEditView{
|
||||
PageTitle: "Edit User — " + target.Username,
|
||||
WindowTitle: "User Edit — " + target.Username,
|
||||
WindowIcon: "U",
|
||||
PageScripts: []string{"/static/js/account-user-edit.js"},
|
||||
AccountNav: app.accountNavView(ctx, "users"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Target: target,
|
||||
Tags: tags,
|
||||
AdminTagID: adminTagID,
|
||||
IsAdmin: isAdmin,
|
||||
IsPending: isPending,
|
||||
Status: status,
|
||||
Perms: effectivePerms,
|
||||
PolicyJSON: policyJSON,
|
||||
CanManage: canManage,
|
||||
IsSelf: actor.ID == target.ID,
|
||||
Error: errMsg,
|
||||
Success: successMsg,
|
||||
TagNames: strings.Join(tagNames, ", "),
|
||||
CreatedAtStr: formatAdminTime(target.CreatedAt),
|
||||
UpdatedAtStr: formatAdminTime(target.UpdatedAt),
|
||||
MaxFileSizeStr: int64PtrStr(target.MaxFileSizeBytes),
|
||||
MaxBoxSizeStr: int64PtrStr(target.MaxBoxSizeBytes),
|
||||
MaxExpiryStr: int64PtrStr(target.MaxExpirySeconds),
|
||||
Check: checks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildPolicyJSON(username string, status string, perms metastore.EffectivePermissions, overrides *metastore.UserPermOverrides) string {
|
||||
type permMap struct {
|
||||
BoxesCreate bool `json:"boxes.create"`
|
||||
ManageOwn bool `json:"boxes.manage_own"`
|
||||
RefreshOwn bool `json:"boxes.refresh_own"`
|
||||
DownloadsZip bool `json:"downloads.zip"`
|
||||
DownloadsOneTime bool `json:"downloads.one_time"`
|
||||
AdminAccess bool `json:"admin.access"`
|
||||
AdminUsers bool `json:"admin.users.manage"`
|
||||
AdminSettings bool `json:"admin.settings.manage"`
|
||||
}
|
||||
type limitsMap struct {
|
||||
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
|
||||
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
|
||||
MaxExpirySeconds int64 `json:"max_expiry_seconds"`
|
||||
}
|
||||
type overridesMap struct {
|
||||
AllowPassword bool `json:"allow_password_protected"`
|
||||
RenewOnAccess bool `json:"renew_on_access"`
|
||||
RenewOnDownload bool `json:"renew_on_download"`
|
||||
AllowOwnerEdit bool `json:"allow_owner_box_editing"`
|
||||
}
|
||||
type preview struct {
|
||||
User string `json:"user"`
|
||||
Status string `json:"status"`
|
||||
Permissions permMap `json:"permissions"`
|
||||
Limits limitsMap `json:"limits"`
|
||||
Overrides overridesMap `json:"overrides"`
|
||||
}
|
||||
|
||||
manageOwn := false
|
||||
allowPwd := false
|
||||
renewAccess := false
|
||||
renewDownload := false
|
||||
allowOwnerEdit := false
|
||||
if overrides != nil {
|
||||
if overrides.ManageOwnBoxes != nil {
|
||||
manageOwn = *overrides.ManageOwnBoxes
|
||||
}
|
||||
if overrides.AllowPasswordProtected != nil {
|
||||
allowPwd = *overrides.AllowPasswordProtected
|
||||
}
|
||||
if overrides.RenewOnAccess != nil {
|
||||
renewAccess = *overrides.RenewOnAccess
|
||||
}
|
||||
if overrides.RenewOnDownload != nil {
|
||||
renewDownload = *overrides.RenewOnDownload
|
||||
}
|
||||
if overrides.AllowOwnerBoxEditing != nil {
|
||||
allowOwnerEdit = *overrides.AllowOwnerBoxEditing
|
||||
}
|
||||
}
|
||||
|
||||
p := preview{
|
||||
User: username,
|
||||
Status: status,
|
||||
Permissions: permMap{
|
||||
BoxesCreate: perms.UploadAllowed,
|
||||
ManageOwn: manageOwn,
|
||||
RefreshOwn: perms.RenewableAllowed,
|
||||
DownloadsZip: perms.ZipDownloadAllowed,
|
||||
DownloadsOneTime: perms.OneTimeDownloadAllowed,
|
||||
AdminAccess: perms.AdminAccess,
|
||||
AdminUsers: perms.AdminUsersManage,
|
||||
AdminSettings: perms.AdminSettingsManage,
|
||||
},
|
||||
Limits: limitsMap{
|
||||
MaxFileSizeBytes: perms.MaxFileSizeBytes,
|
||||
MaxBoxSizeBytes: perms.MaxBoxSizeBytes,
|
||||
MaxExpirySeconds: perms.MaxExpirySeconds,
|
||||
},
|
||||
Overrides: overridesMap{
|
||||
AllowPassword: allowPwd,
|
||||
RenewOnAccess: renewAccess,
|
||||
RenewOnDownload: renewDownload,
|
||||
AllowOwnerEdit: allowOwnerEdit,
|
||||
},
|
||||
}
|
||||
data, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (app *App) checkLastAdminDisable(ids []string) error {
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok {
|
||||
return nil
|
||||
}
|
||||
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
removing := 0
|
||||
for _, id := range ids {
|
||||
u, found, _ := app.store.GetUser(id)
|
||||
if found && !u.Disabled && containsString(u.TagIDs, adminTag.ID) {
|
||||
removing++
|
||||
}
|
||||
}
|
||||
if adminCount-removing < 1 {
|
||||
return fmt.Errorf("cannot remove the last active administrator")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func int64PtrStr(v *int64) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d", *v)
|
||||
}
|
||||
|
||||
func redirectUserEdit(ctx *gin.Context, userID string, errMsg string, successMsg string) {
|
||||
base := "/account/users/" + userID
|
||||
if errMsg != "" {
|
||||
ctx.Redirect(http.StatusSeeOther, base+"?error="+errMsg)
|
||||
} else if successMsg != "" {
|
||||
ctx.Redirect(http.StatusSeeOther, base+"?success="+successMsg)
|
||||
} else {
|
||||
ctx.Redirect(http.StatusSeeOther, base)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeString(slice []string, s string) []string {
|
||||
out := make([]string, 0, len(slice))
|
||||
for _, v := range slice {
|
||||
if v != s {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/alerts"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
ip := clientIP(ctx)
|
||||
guard := app.securityGuard
|
||||
if guard == nil {
|
||||
guard = security.NewGuard()
|
||||
app.securityGuard = guard
|
||||
}
|
||||
if !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) {
|
||||
app.logActivity("auth.admin.block", "high", "Blocked admin login from banned IP", ctx, nil)
|
||||
ctx.HTML(http.StatusTooManyRequests, "admin/login.html", gin.H{
|
||||
"ErrorMessage": "Too many failed attempts. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
password := ctx.PostForm("password")
|
||||
|
||||
if username != app.config.AdminUsername || password != app.config.AdminPassword {
|
||||
if !guard.IsAdminWhitelisted(ip) {
|
||||
banned, attempts := guard.RegisterFailedLogin(ip, app.config.SecurityLoginWindowSeconds, app.config.SecurityLoginMaxAttempts, app.config.SecurityBanSeconds)
|
||||
app.logActivity("auth.admin.failed", "medium", "Failed admin login", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
|
||||
if banned {
|
||||
app.createAlert("Admin login brute-force blocked", "high", "security", "401", "auth.admin.bruteforce", "Too many failed admin logins triggered temporary ban.", map[string]string{"ip": ip, "attempts": strconv.Itoa(attempts)})
|
||||
app.logActivity("security.ban", "high", "Auto-banned IP after admin login failures", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
|
||||
}
|
||||
}
|
||||
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
|
||||
"ErrorMessage": "Invalid username or password.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil)
|
||||
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
|
||||
}
|
||||
|
||||
alertsList := []alerts.Alert{}
|
||||
if app.alertStore != nil {
|
||||
var err error
|
||||
alertsList, err = app.alertStore.List(500)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load alerts")
|
||||
return
|
||||
}
|
||||
}
|
||||
openCount := 0
|
||||
highCount := 0
|
||||
ackedCount := 0
|
||||
closedCount := 0
|
||||
for _, alert := range alertsList {
|
||||
switch string(alert.Status) {
|
||||
case "open":
|
||||
openCount++
|
||||
case "acked":
|
||||
ackedCount++
|
||||
case "closed":
|
||||
closedCount++
|
||||
}
|
||||
if alert.Severity == "high" && string(alert.Status) != "closed" {
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "alerts",
|
||||
"Alerts": alertsList,
|
||||
"OpenCount": strconv.Itoa(openCount),
|
||||
"HighCount": strconv.Itoa(highCount),
|
||||
"AckCount": strconv.Itoa(ackedCount),
|
||||
"ClosedCount": strconv.Itoa(closedCount),
|
||||
})
|
||||
}
|
||||
195
lib/server/admin_auth.go
Normal file
195
lib/server/admin_auth.go
Normal file
@@ -0,0 +1,195 @@
|
||||
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")
|
||||
if token == "" {
|
||||
token = ctx.GetHeader("X-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
|
||||
}
|
||||
@@ -1,316 +1,63 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/helpers"
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
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"`
|
||||
type adminBoxRow struct {
|
||||
ID string
|
||||
FileCount int
|
||||
TotalSizeLabel string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Expired bool
|
||||
OneTimeDownload bool
|
||||
PasswordProtected bool
|
||||
}
|
||||
|
||||
func (app *App) handleAdminBoxes(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) {
|
||||
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 {
|
||||
return nil, err
|
||||
ctx.String(http.StatusInternalServerError, "Could not list boxes")
|
||||
return
|
||||
}
|
||||
|
||||
boxes := make([]adminBoxView, 0, len(summaries))
|
||||
rows := make([]adminBoxRow, 0, len(summaries))
|
||||
totalSize := int64(0)
|
||||
expiredCount := 0
|
||||
for _, summary := range summaries {
|
||||
boxView, err := app.buildAdminBoxView(summary.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
totalSize += summary.TotalSize
|
||||
if summary.Expired {
|
||||
expiredCount++
|
||||
}
|
||||
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,
|
||||
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)),
|
||||
}
|
||||
|
||||
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,
|
||||
rows = append(rows, adminBoxRow{
|
||||
ID: summary.ID,
|
||||
FileCount: summary.FileCount,
|
||||
TotalSizeLabel: summary.TotalSizeLabel,
|
||||
CreatedAt: formatAdminTime(summary.CreatedAt),
|
||||
ExpiresAt: formatAdminTime(summary.ExpiresAt),
|
||||
Expired: summary.Expired,
|
||||
OneTimeDownload: summary.OneTimeDownload,
|
||||
PasswordProtected: summary.PasswordProtected,
|
||||
})
|
||||
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()
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
14
lib/server/admin_dashboard.go
Normal file
14
lib/server/admin_dashboard.go
Normal file
@@ -0,0 +1,14 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
73
lib/server/admin_format.go
Normal file
73
lib/server/admin_format.go
Normal file
@@ -0,0 +1,73 @@
|
||||
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")
|
||||
}
|
||||
37
lib/server/admin_routes.go
Normal file
37
lib/server/admin_routes.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (app *App) registerAdminRoutes(router *gin.Engine) {
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(noStoreAdminHeaders)
|
||||
admin.GET("/login", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
})
|
||||
admin.POST("/login", app.handleAdminLoginPost)
|
||||
admin.GET("", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
})
|
||||
admin.GET("/", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account")
|
||||
})
|
||||
|
||||
protected := admin.Group("")
|
||||
protected.Use(app.requireAdminSession)
|
||||
protected.POST("/logout", app.handleAdminLogout)
|
||||
protected.GET("/boxes", app.handleAdminBoxes)
|
||||
protected.GET("/users", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||
})
|
||||
protected.POST("/users", func(ctx *gin.Context) {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/users")
|
||||
})
|
||||
protected.GET("/tags", app.handleAdminTags)
|
||||
protected.POST("/tags", app.handleAdminTagsPost)
|
||||
protected.GET("/settings", app.handleAdminSettings)
|
||||
protected.POST("/settings", app.handleAdminSettingsPost)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/activity"
|
||||
"warpbox/lib/alerts"
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
type adminAlertsActionRequest struct {
|
||||
Action string `json:"action"`
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
type adminSecurityActionRequest struct {
|
||||
Action string `json:"action"`
|
||||
IP string `json:"ip"`
|
||||
BanUntil string `json:"ban_until"`
|
||||
}
|
||||
|
||||
func (app *App) reloadSecurityConfig() {
|
||||
if app.securityGuard == nil {
|
||||
app.securityGuard = security.NewGuard()
|
||||
}
|
||||
app.securityGuard.Reload(security.Config{
|
||||
IPWhitelist: app.config.SecurityIPWhitelist,
|
||||
AdminIPWhitelist: app.config.SecurityAdminIPWhitelist,
|
||||
LoginWindowSeconds: app.config.SecurityLoginWindowSeconds,
|
||||
LoginMaxAttempts: app.config.SecurityLoginMaxAttempts,
|
||||
BanSeconds: app.config.SecurityBanSeconds,
|
||||
ScanWindowSeconds: app.config.SecurityScanWindowSeconds,
|
||||
ScanMaxAttempts: app.config.SecurityScanMaxAttempts,
|
||||
UploadWindowSeconds: app.config.SecurityUploadWindowSeconds,
|
||||
UploadMaxRequests: app.config.SecurityUploadMaxRequests,
|
||||
UploadMaxBytes: app.config.SecurityUploadMaxBytes,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) logActivity(kind string, severity string, message string, ctx *gin.Context, meta map[string]string) {
|
||||
if app.activityStore == nil {
|
||||
return
|
||||
}
|
||||
event := activity.Event{
|
||||
Kind: kind,
|
||||
Severity: severity,
|
||||
Message: message,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Meta: meta,
|
||||
}
|
||||
if ctx != nil {
|
||||
event.IP = clientIP(ctx)
|
||||
event.Path = ctx.Request.URL.Path
|
||||
event.Method = ctx.Request.Method
|
||||
}
|
||||
_ = app.activityStore.Append(event, app.config.ActivityRetentionSeconds)
|
||||
}
|
||||
|
||||
func (app *App) createAlert(title string, severity string, group string, code string, trace string, message string, meta map[string]string) {
|
||||
if app.alertStore == nil {
|
||||
return
|
||||
}
|
||||
_ = app.alertStore.Add(alerts.Alert{
|
||||
Title: title,
|
||||
Severity: severity,
|
||||
Group: group,
|
||||
Code: code,
|
||||
Trace: trace,
|
||||
Message: message,
|
||||
Status: alerts.StatusOpen,
|
||||
Meta: meta,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) securityMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
if app.securityGuard == nil {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
ip := clientIP(ctx)
|
||||
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||||
ctx.Next()
|
||||
return
|
||||
}
|
||||
if app.securityGuard.IsBanned(ip) {
|
||||
app.logActivity("security.block", "high", "Blocked banned IP", ctx, nil)
|
||||
ctx.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Too many abusive requests. Try again later."})
|
||||
return
|
||||
}
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleNoRoute(ctx *gin.Context) {
|
||||
if app.securityGuard == nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
return
|
||||
}
|
||||
path := strings.ToLower(ctx.Request.URL.Path)
|
||||
suspicious := strings.Contains(path, "../") || strings.Contains(path, ".php") || strings.Contains(path, "wp-admin") || strings.Contains(path, ".env")
|
||||
if suspicious {
|
||||
ip := clientIP(ctx)
|
||||
if !app.securityGuard.IsWhitelisted(ip) {
|
||||
banned, attempts := app.securityGuard.RegisterScanAttempt(ip, app.config.SecurityScanWindowSeconds, app.config.SecurityScanMaxAttempts, app.config.SecurityBanSeconds)
|
||||
app.logActivity("security.scan", "medium", "Suspicious path probe detected", ctx, map[string]string{"attempts": intToString(attempts)})
|
||||
if banned {
|
||||
app.createAlert("IP auto-banned after malicious path scans", "high", "security", "410", "security.scan.autoban", "Repeated malicious path scans triggered temporary ban.", map[string]string{"ip": ip, "attempts": intToString(attempts)})
|
||||
app.logActivity("security.ban", "high", "IP auto-banned after scans", ctx, map[string]string{"attempts": intToString(attempts)})
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminActivity(ctx *gin.Context) {
|
||||
if app.activityStore == nil {
|
||||
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "activity",
|
||||
"Events": []activity.Event{},
|
||||
})
|
||||
return
|
||||
}
|
||||
events, err := app.activityStore.List(400, app.config.ActivityRetentionSeconds)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not load activity")
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/activity.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "activity",
|
||||
"Events": events,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSecurity(ctx *gin.Context) {
|
||||
events := []activity.Event{}
|
||||
alertsList := []alerts.Alert{}
|
||||
if app.activityStore != nil {
|
||||
events, _ = app.activityStore.List(100, app.config.ActivityRetentionSeconds)
|
||||
}
|
||||
if app.alertStore != nil {
|
||||
alertsList, _ = app.alertStore.List(50)
|
||||
}
|
||||
bans := []security.BanEntry{}
|
||||
if app.securityGuard != nil {
|
||||
bans = app.securityGuard.BanList()
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/security.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "security",
|
||||
"Events": events,
|
||||
"Alerts": alertsList,
|
||||
"Bans": bans,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminAlertsAction(ctx *gin.Context) {
|
||||
if app.alertStore == nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Alert store unavailable"})
|
||||
return
|
||||
}
|
||||
var request adminAlertsActionRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||||
return
|
||||
}
|
||||
switch request.Action {
|
||||
case "ack":
|
||||
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusAcked); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
||||
return
|
||||
}
|
||||
case "close":
|
||||
if err := app.alertStore.SetStatus(request.IDs, alerts.StatusClosed); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not update alerts"})
|
||||
return
|
||||
}
|
||||
case "delete":
|
||||
if err := app.alertStore.Delete(request.IDs); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not delete alerts"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
return
|
||||
}
|
||||
app.logActivity("alerts.action", "low", "Admin changed alert state", ctx, map[string]string{"action": request.Action, "count": intToString(len(request.IDs))})
|
||||
alertsList, _ := app.alertStore.List(500)
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "alerts": alertsList})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
|
||||
if app.securityGuard == nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Security guard unavailable"})
|
||||
return
|
||||
}
|
||||
var request adminSecurityActionRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
|
||||
return
|
||||
}
|
||||
ip := strings.TrimSpace(request.IP)
|
||||
if ip != "" && net.ParseIP(ip) == nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid IP"})
|
||||
return
|
||||
}
|
||||
switch request.Action {
|
||||
case "ban":
|
||||
if ip == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||||
return
|
||||
}
|
||||
app.securityGuard.Ban(ip, app.config.SecurityBanSeconds)
|
||||
app.logActivity("security.manual_ban", "high", "Admin banned IP", ctx, map[string]string{"ip": ip})
|
||||
app.createAlert("IP manually banned by admin", "medium", "security", "420", "security.manual.ban", "Admin manually applied temporary ban.", map[string]string{"ip": ip})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP banned", "bans": app.securityGuard.BanList()})
|
||||
case "ban_until":
|
||||
if ip == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||||
return
|
||||
}
|
||||
until, err := time.Parse(time.RFC3339, strings.TrimSpace(request.BanUntil))
|
||||
if err != nil || until.Before(time.Now().UTC()) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ban expiration"})
|
||||
return
|
||||
}
|
||||
app.securityGuard.BanUntil(ip, until)
|
||||
app.logActivity("security.manual_ban_until", "high", "Admin set custom ban expiration", ctx, map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
|
||||
app.createAlert("Custom IP ban applied by admin", "medium", "security", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.", map[string]string{"ip": ip, "until": until.UTC().Format(time.RFC3339)})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP ban expiration updated", "bans": app.securityGuard.BanList()})
|
||||
case "unban":
|
||||
if ip == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP"})
|
||||
return
|
||||
}
|
||||
app.securityGuard.Unban(ip)
|
||||
app.logActivity("security.manual_unban", "medium", "Admin unbanned IP", ctx, map[string]string{"ip": ip})
|
||||
app.createAlert("IP unbanned by admin", "low", "security", "422", "security.manual.unban", "Admin manually removed temporary ban.", map[string]string{"ip": ip})
|
||||
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
|
||||
default:
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||
}
|
||||
}
|
||||
|
||||
func intToString(value int) string {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
@@ -1,491 +1,58 @@
|
||||
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) {
|
||||
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,
|
||||
})
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
return
|
||||
}
|
||||
app.renderAdminSettings(ctx, "")
|
||||
}
|
||||
|
||||
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"})
|
||||
func (app *App) handleAdminSettingsPost(ctx *gin.Context) {
|
||||
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) {
|
||||
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{}
|
||||
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
value := ctx.PostForm(def.Key)
|
||||
if def.Type == config.SettingTypeBool {
|
||||
value = "false"
|
||||
if ctx.PostForm(def.Key) == "true" {
|
||||
value = "true"
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
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)
|
||||
app.reloadSecurityConfig()
|
||||
rows, _ := app.buildAdminSettingsRows()
|
||||
return rows, warnings, nil
|
||||
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
|
||||
}
|
||||
|
||||
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)
|
||||
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,
|
||||
})
|
||||
|
||||
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: "security", Label: "Security", Icon: "🔒"},
|
||||
{Key: "activity", Label: "Activity", 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.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB:
|
||||
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.SettingSecurityIPWhitelist, config.SettingSecurityAdminIPWhitelist, config.SettingSecurityLoginWindowSecs, config.SettingSecurityLoginMaxAttempts, config.SettingSecurityBanSeconds, config.SettingSecurityScanWindowSecs, config.SettingSecurityScanMaxAttempts:
|
||||
return "security"
|
||||
case config.SettingActivityRetentionSeconds:
|
||||
return "activity"
|
||||
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.",
|
||||
config.SettingActivityRetentionSeconds: "How long activity events stay stored before automatic prune.",
|
||||
config.SettingSecurityIPWhitelist: "Comma-separated IPs that bypass generic security bans and rate-limits.",
|
||||
config.SettingSecurityAdminIPWhitelist: "Comma-separated IPs allowed to bypass admin login brute-force controls.",
|
||||
config.SettingSecurityLoginWindowSecs: "Window used for failed admin login counting.",
|
||||
config.SettingSecurityLoginMaxAttempts: "Max failed admin logins per window before temporary ban.",
|
||||
config.SettingSecurityBanSeconds: "Duration for automatic temporary IP bans.",
|
||||
config.SettingSecurityScanWindowSecs: "Window used for malicious path scan detection.",
|
||||
config.SettingSecurityScanMaxAttempts: "Max suspicious path probes per window before temporary ban.",
|
||||
config.SettingSecurityUploadWindowSecs: "Window used for per-IP upload throttling.",
|
||||
config.SettingSecurityUploadMaxRequests: "Max upload requests per IP per upload window.",
|
||||
config.SettingSecurityUploadMaxGB: "Max upload volume in GB per IP per upload window.",
|
||||
}
|
||||
return descriptions[key]
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
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, "")
|
||||
}
|
||||
}
|
||||
122
lib/server/admin_tags.go
Normal file
122
lib/server/admin_tags.go
Normal file
@@ -0,0 +1,122 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,20 +1,393 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/metastore"
|
||||
)
|
||||
|
||||
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||
if !app.adminLoginEnabled() {
|
||||
ctx.Redirect(http.StatusSeeOther, "/")
|
||||
const defaultUserPageSize = 12
|
||||
|
||||
type UsersIndexView struct {
|
||||
PageTitle string
|
||||
WindowTitle string
|
||||
WindowIcon string
|
||||
PageScripts []string
|
||||
AccountNav AccountNavView
|
||||
CSRFToken string
|
||||
Filters UserFiltersView
|
||||
Rows []metastore.UserRow
|
||||
Stats metastore.UserPageStats
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
TotalPages int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
CanManage bool
|
||||
Tags []metastore.Tag
|
||||
Error string
|
||||
Success string
|
||||
}
|
||||
|
||||
type UserFiltersView struct {
|
||||
Query string
|
||||
Status string
|
||||
Role string
|
||||
Sort string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsers(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "users",
|
||||
})
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersView && !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
filters := userFiltersFromRequest(ctx)
|
||||
pageReq := userPageFromRequest(ctx)
|
||||
|
||||
userPage, err := app.store.ListUsersPaginated(filters, pageReq)
|
||||
if err != nil {
|
||||
ctx.String(http.StatusInternalServerError, "Could not list users")
|
||||
return
|
||||
}
|
||||
|
||||
currentID := actor.ID
|
||||
for i := range userPage.Rows {
|
||||
if userPage.Rows[i].ID == currentID {
|
||||
userPage.Rows[i].IsCurrent = true
|
||||
}
|
||||
}
|
||||
|
||||
tags, _ := app.store.ListTags()
|
||||
|
||||
view := UsersIndexView{
|
||||
PageTitle: "WarpBox Users",
|
||||
WindowTitle: "WarpBox Users",
|
||||
WindowIcon: "U",
|
||||
PageScripts: []string{"/static/js/account-users.js"},
|
||||
AccountNav: app.accountNavView(ctx, "users"),
|
||||
CSRFToken: app.currentCSRFToken(ctx),
|
||||
Filters: UserFiltersView{
|
||||
Query: filters.Query,
|
||||
Status: filters.Status,
|
||||
Role: filters.Role,
|
||||
Sort: filters.Sort,
|
||||
PageSize: pageReq.PageSize,
|
||||
},
|
||||
Rows: userPage.Rows,
|
||||
Stats: userPage.Stats,
|
||||
Page: userPage.Page,
|
||||
PageSize: userPage.PageSize,
|
||||
Total: userPage.Total,
|
||||
TotalPages: userPage.TotalPages,
|
||||
HasPrev: userPage.HasPrev,
|
||||
HasNext: userPage.HasNext,
|
||||
CanManage: perms.AdminUsersManage,
|
||||
Tags: tags,
|
||||
Error: ctx.Query("error"),
|
||||
Success: ctx.Query("success"),
|
||||
}
|
||||
|
||||
ctx.HTML(http.StatusOK, "account_users.html", view)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersPost(ctx *gin.Context) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
action := ctx.PostForm("action")
|
||||
switch action {
|
||||
case "create":
|
||||
app.handleAccountUsersCreate(ctx, actor)
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Unknown action", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersCreate(ctx *gin.Context, _ metastore.User) {
|
||||
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||
email := strings.TrimSpace(ctx.PostForm("email"))
|
||||
mode := strings.TrimSpace(ctx.PostForm("mode"))
|
||||
role := strings.TrimSpace(ctx.PostForm("role"))
|
||||
|
||||
if username == "" {
|
||||
redirectAccountUsers(ctx, "Username is required.", "")
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
redirectAccountUsers(ctx, "Email is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
var tagIDs []string
|
||||
if role != "" && role != "all" {
|
||||
tag, ok, err := app.store.GetTagByName(role)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not look up role.", "")
|
||||
return
|
||||
}
|
||||
if ok {
|
||||
tagIDs = append(tagIDs, tag.ID)
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "invite":
|
||||
user, err := app.store.CreateUserWithoutPassword(username, email, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
msg := fmt.Sprintf("Invite user created. Setup link: %s (Email delivery not yet implemented.)", inviteLink)
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
case "create":
|
||||
password := strings.TrimSpace(ctx.PostForm("password"))
|
||||
autoGen := false
|
||||
if password == "" {
|
||||
password = randomPassword()
|
||||
autoGen = true
|
||||
}
|
||||
user, err := app.store.CreateUserWithPassword(username, email, password, tagIDs)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, err.Error(), "")
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("User %s created.", user.Username)
|
||||
if autoGen {
|
||||
msg = fmt.Sprintf("User %s created. Temporary password: %s", user.Username, password)
|
||||
}
|
||||
redirectAccountUsers(ctx, "", msg)
|
||||
|
||||
default:
|
||||
redirectAccountUsers(ctx, "Select create or invite mode.", "")
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkDisable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, true)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkEnable(ctx *gin.Context) {
|
||||
app.handleAccountUsersBulkSetDisabled(ctx, false)
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkSetDisabled(ctx *gin.Context, disabled bool) {
|
||||
actor, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if id == actor.ID {
|
||||
redirectAccountUsers(ctx, "You cannot disable yourself.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if disabled {
|
||||
adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
adminCount, err := app.store.CountAdminUsers(adminTag.ID)
|
||||
if err != nil {
|
||||
redirectAccountUsers(ctx, "Could not verify admin protection.", "")
|
||||
return
|
||||
}
|
||||
disableAdminCount := 0
|
||||
for _, id := range ids {
|
||||
user, ok, err := app.store.GetUser(id)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
if !user.Disabled {
|
||||
for _, tagID := range user.TagIDs {
|
||||
if tagID == adminTag.ID {
|
||||
disableAdminCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if adminCount-disableAdminCount < 1 {
|
||||
redirectAccountUsers(ctx, "Cannot disable the last active administrator.", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.store.BulkSetUsersDisabled(ids, disabled); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not update users.", "")
|
||||
return
|
||||
}
|
||||
|
||||
action := "disabled"
|
||||
if !disabled {
|
||||
action = "enabled"
|
||||
}
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("%d user(s) %s.", len(ids), action))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersBulkRevokeSessions(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
ids := parseSelectedIDs(ctx)
|
||||
if len(ids) == 0 {
|
||||
redirectAccountUsers(ctx, "No users selected.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := app.store.BulkRevokeUserSessions(ids); err != nil {
|
||||
redirectAccountUsers(ctx, "Could not revoke sessions.", "")
|
||||
return
|
||||
}
|
||||
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Sessions revoked for %d user(s).", len(ids)))
|
||||
}
|
||||
|
||||
func (app *App) handleAccountUsersResendInvite(ctx *gin.Context) {
|
||||
_, ok := currentAccountUser(ctx)
|
||||
if !ok {
|
||||
ctx.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
perms := currentAccountPermissions(ctx)
|
||||
if !perms.AdminUsersManage {
|
||||
ctx.String(http.StatusForbidden, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ctx.Param("id"))
|
||||
if userID == "" {
|
||||
redirectAccountUsers(ctx, "User ID is required.", "")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok, err := app.store.GetUser(userID)
|
||||
if err != nil || !ok {
|
||||
redirectAccountUsers(ctx, "User not found.", "")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(user.PasswordHash, "invite/") {
|
||||
redirectAccountUsers(ctx, "This user is not a pending invite.", "")
|
||||
return
|
||||
}
|
||||
|
||||
inviteLink := fmt.Sprintf("/account/setup?token=%s", strings.TrimPrefix(user.PasswordHash, "invite/"))
|
||||
redirectAccountUsers(ctx, "", fmt.Sprintf("Invite link: %s (Email delivery not yet implemented.)", inviteLink))
|
||||
}
|
||||
|
||||
func userFiltersFromRequest(ctx *gin.Context) metastore.UserFilters {
|
||||
return metastore.UserFilters{
|
||||
Query: strings.TrimSpace(ctx.Query("q")),
|
||||
Status: strings.TrimSpace(ctx.Query("status")),
|
||||
Role: strings.TrimSpace(ctx.Query("role")),
|
||||
Sort: strings.TrimSpace(ctx.Query("sort")),
|
||||
}
|
||||
}
|
||||
|
||||
func userPageFromRequest(ctx *gin.Context) metastore.UserPageRequest {
|
||||
page := 1
|
||||
if p, err := strconv.Atoi(ctx.Query("page")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
pageSize := defaultUserPageSize
|
||||
if ps, err := strconv.Atoi(ctx.Query("page_size")); err == nil && ps >= 1 && ps <= 100 {
|
||||
pageSize = ps
|
||||
}
|
||||
return metastore.UserPageRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSelectedIDs(ctx *gin.Context) []string {
|
||||
raw := ctx.PostForm("selected_ids")
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
id := strings.TrimSpace(part)
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func redirectAccountUsers(ctx *gin.Context, errorMsg string, successMsg string) {
|
||||
redirectURL := "/account/users"
|
||||
if errorMsg != "" {
|
||||
redirectURL = "/account/users?error=" + errorMsg
|
||||
} else if successMsg != "" {
|
||||
redirectURL = "/account/users?success=" + successMsg
|
||||
}
|
||||
ctx.Redirect(http.StatusSeeOther, redirectURL)
|
||||
}
|
||||
|
||||
func randomPassword() string {
|
||||
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
result := make([]byte, 16)
|
||||
for i := range result {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
if err != nil {
|
||||
return "changeme123"
|
||||
}
|
||||
result[i] = charset[n.Int64()]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func clientIP(ctx *gin.Context) string {
|
||||
if ctx == nil || ctx.Request == nil {
|
||||
return ""
|
||||
}
|
||||
remoteIP := remoteAddrIP(ctx.Request)
|
||||
|
||||
// Only trust forwarding headers when remote hop looks like local/internal proxy.
|
||||
if isPrivateOrLoopback(remoteIP) {
|
||||
for _, candidate := range headerIPs(ctx.Request.Header) {
|
||||
if isPublicIP(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
candidates := headerIPs(ctx.Request.Header)
|
||||
if len(candidates) > 0 {
|
||||
return candidates[0]
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func headerIPs(header http.Header) []string {
|
||||
keys := []string{
|
||||
"X-Forwarded-For",
|
||||
"X-Real-Ip",
|
||||
"CF-Connecting-IP",
|
||||
"X-Envoy-External-Address",
|
||||
"Fly-Client-IP",
|
||||
}
|
||||
out := make([]string, 0, 4)
|
||||
seen := map[string]bool{}
|
||||
for _, key := range keys {
|
||||
raw := strings.TrimSpace(header.Get(key))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
ip := normalizeIP(strings.TrimSpace(part))
|
||||
if ip == "" || seen[ip] {
|
||||
continue
|
||||
}
|
||||
seen[ip] = true
|
||||
out = append(out, ip)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func remoteAddrIP(request *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(request.RemoteAddr))
|
||||
if err != nil {
|
||||
return normalizeIP(strings.TrimSpace(request.RemoteAddr))
|
||||
}
|
||||
return normalizeIP(host)
|
||||
}
|
||||
|
||||
func normalizeIP(raw string) string {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
|
||||
func isPublicIP(value string) bool {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil || !ip.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
return !isPrivateOrLoopback(value)
|
||||
}
|
||||
|
||||
func isPrivateOrLoopback(value string) bool {
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -35,3 +40,40 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/activity"
|
||||
"warpbox/lib/alerts"
|
||||
"warpbox/lib/boxstore"
|
||||
"warpbox/lib/config"
|
||||
"warpbox/lib/metastore"
|
||||
"warpbox/lib/routing"
|
||||
"warpbox/lib/security"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config *config.Config
|
||||
settingsOverridesPath string
|
||||
activityStore *activity.Store
|
||||
alertStore *alerts.Store
|
||||
securityGuard *security.Guard
|
||||
config *config.Config
|
||||
store *metastore.Store
|
||||
adminLoginEnabled bool
|
||||
}
|
||||
|
||||
func Run(addr string) error {
|
||||
@@ -33,37 +27,39 @@ func Run(addr string) error {
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
return err
|
||||
}
|
||||
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
|
||||
overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
settingsOverridesPath: overridesPath,
|
||||
activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity_log.json")),
|
||||
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
|
||||
securityGuard: security.NewGuard(),
|
||||
store, err := metastore.Open(cfg.DBDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open metadata database: %w", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
overrides, err := store.ListSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load settings overrides: %w", err)
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("apply settings overrides: %w", err)
|
||||
}
|
||||
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.reloadSecurityConfig()
|
||||
|
||||
router := gin.Default()
|
||||
router.Use(app.securityMiddleware())
|
||||
router.NoRoute(app.handleNoRoute)
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
router.SetHTMLTemplate(htmlTemplates)
|
||||
router.LoadHTMLGlob("templates/*.html")
|
||||
|
||||
routing.Register(router, routing.Handlers{
|
||||
Health: app.handleHealth,
|
||||
Index: app.handleIndex,
|
||||
ShowBox: app.handleShowBox,
|
||||
BoxLogin: handleBoxLogin,
|
||||
@@ -77,26 +73,9 @@ 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,
|
||||
AdminActivity: app.handleAdminActivity,
|
||||
AdminSecurity: app.handleAdminSecurity,
|
||||
AdminAlertsAction: app.handleAdminAlertsAction,
|
||||
AdminSecurityAction: app.handleAdminSecurityAction,
|
||||
AdminSettings: app.handleAdminSettings,
|
||||
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||
AdminSettingsImport: app.handleAdminSettingsImport,
|
||||
AdminSettingsReset: app.handleAdminSettingsReset,
|
||||
AdminAuth: app.adminAuthMiddleware,
|
||||
})
|
||||
app.registerAccountRoutes(router)
|
||||
app.registerAdminRoutes(router)
|
||||
|
||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||
compressed.Static("/static", "./static")
|
||||
@@ -106,37 +85,7 @@ 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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,19 +39,13 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
totalSize := int64(0)
|
||||
for _, file := range request.Files {
|
||||
totalSize += file.Size
|
||||
}
|
||||
if !app.enforceUploadRateLimit(ctx, totalSize) {
|
||||
return
|
||||
}
|
||||
|
||||
files, err := boxstore.CreateManifest(boxID, request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
|
||||
}
|
||||
@@ -80,10 +74,6 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !app.enforceUploadRateLimit(ctx, file.Size) {
|
||||
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||
return
|
||||
}
|
||||
|
||||
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
||||
if err != nil {
|
||||
@@ -91,6 +81,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
|
||||
}
|
||||
@@ -127,6 +118,7 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"file": file})
|
||||
}
|
||||
@@ -152,9 +144,6 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !app.enforceUploadRateLimit(ctx, file.Size) {
|
||||
return
|
||||
}
|
||||
|
||||
savedFile, err := boxstore.SaveUpload(boxID, file)
|
||||
if err != nil {
|
||||
@@ -194,9 +183,6 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !app.enforceUploadRateLimit(ctx, totalSize) {
|
||||
return
|
||||
}
|
||||
|
||||
boxID, err := boxstore.NewBoxID()
|
||||
if err != nil {
|
||||
@@ -248,6 +234,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
||||
|
||||
savedFiles = append(savedFiles, savedFile)
|
||||
}
|
||||
app.indexBoxFromManifest(boxID)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -154,36 +153,3 @@ func (app *App) maxRequestBodyBytes() int64 {
|
||||
}
|
||||
return limit + 10*1024*1024
|
||||
}
|
||||
|
||||
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
|
||||
ip := clientIP(ctx)
|
||||
if app.securityGuard.IsWhitelisted(ip) || app.securityGuard.IsAdminWhitelisted(ip) {
|
||||
return true
|
||||
}
|
||||
allowed, requestCount, totalBytes := app.securityGuard.AllowUpload(
|
||||
ip,
|
||||
size,
|
||||
app.config.SecurityUploadWindowSeconds,
|
||||
app.config.SecurityUploadMaxRequests,
|
||||
app.config.SecurityUploadMaxBytes,
|
||||
)
|
||||
if allowed {
|
||||
return true
|
||||
}
|
||||
|
||||
app.logActivity("security.upload_limit", "high", "Upload rate limit exceeded", ctx, map[string]string{
|
||||
"requests": strconv.Itoa(requestCount),
|
||||
"bytes": strconv.FormatInt(totalBytes, 10),
|
||||
})
|
||||
app.createAlert(
|
||||
"Upload rate limit triggered",
|
||||
"medium",
|
||||
"security",
|
||||
"430",
|
||||
"security.upload.rate_limit",
|
||||
"Per-IP upload rate limit blocked request.",
|
||||
map[string]string{"ip": ip, "requests": strconv.Itoa(requestCount)},
|
||||
)
|
||||
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "Too many uploads from this IP. Try again later."})
|
||||
return false
|
||||
}
|
||||
|
||||
17
run.sh
17
run.sh
@@ -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 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
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -25,17 +25,6 @@ export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172
|
||||
export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
|
||||
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
|
||||
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
|
||||
export WARPBOX_ACTIVITY_RETENTION_SECONDS="${WARPBOX_ACTIVITY_RETENTION_SECONDS:-604800}"
|
||||
export WARPBOX_SECURITY_IP_WHITELIST="${WARPBOX_SECURITY_IP_WHITELIST:-}"
|
||||
export WARPBOX_SECURITY_ADMIN_IP_WHITELIST="${WARPBOX_SECURITY_ADMIN_IP_WHITELIST:-}"
|
||||
export WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS="${WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS:-600}"
|
||||
export WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS="${WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS:-8}"
|
||||
export WARPBOX_SECURITY_BAN_SECONDS="${WARPBOX_SECURITY_BAN_SECONDS:-1800}"
|
||||
export WARPBOX_SECURITY_SCAN_WINDOW_SECONDS="${WARPBOX_SECURITY_SCAN_WINDOW_SECONDS:-300}"
|
||||
export WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS="${WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS:-12}"
|
||||
export WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS="${WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS:-60}"
|
||||
export WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS="${WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS:-20}"
|
||||
export WARPBOX_SECURITY_UPLOAD_MAX_GB="${WARPBOX_SECURITY_UPLOAD_MAX_GB:-10}"
|
||||
|
||||
# Data location.
|
||||
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
|
||||
|
||||
2034
static/css/account.css
Normal file
2034
static/css/account.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,63 +0,0 @@
|
||||
.activity-page-body { display: grid; gap: 10px; }
|
||||
.activity-panel {
|
||||
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;
|
||||
padding: 10px;
|
||||
}
|
||||
.activity-toolbar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.2fr) minmax(130px, .4fr) minmax(150px, .5fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.activity-input,
|
||||
.activity-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;
|
||||
}
|
||||
.activity-table-wrap {
|
||||
min-height: 420px;
|
||||
height: 520px;
|
||||
overflow: auto;
|
||||
background: #ffffff;
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
.activity-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.activity-table th,
|
||||
.activity-table td {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #e1e1e1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.activity-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #dfdfdf;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -1,738 +1,132 @@
|
||||
/* ===========================
|
||||
Admin Shell / Frame
|
||||
=========================== */
|
||||
.admin-shell {
|
||||
width: 100%;
|
||||
body {
|
||||
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;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 10px 16px 34px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-frame {
|
||||
width: min(var(--admin-frame-width, 1320px), 100%);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Admin Taskbar (top nav)
|
||||
=========================== */
|
||||
.admin-taskbar {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
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;
|
||||
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-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-start-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
.admin-grid {
|
||||
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;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
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;
|
||||
.admin-link {
|
||||
min-height: 88px;
|
||||
padding: 12px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.admin-link strong,
|
||||
.admin-link span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-menu-action {
|
||||
.admin-link span {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
min-height: 22px;
|
||||
display: grid;
|
||||
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;
|
||||
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-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 {
|
||||
.admin-form {
|
||||
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 {
|
||||
.admin-form-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: center;
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.admin-form-row input,
|
||||
.admin-form-row textarea,
|
||||
.admin-form-row select {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
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: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.admin-hero-status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.admin-checks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
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(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.admin-checks label {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin: 0;
|
||||
color: #222222;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-stat-note-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 18px;
|
||||
padding: 1px 6px;
|
||||
.admin-error {
|
||||
padding: 8px;
|
||||
border: 1px solid #800;
|
||||
background: #ffdede;
|
||||
}
|
||||
|
||||
.admin-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-summary span {
|
||||
padding: 6px 8px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,501 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/* ==============================================
|
||||
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); }
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
.security-page-body { display: grid; gap: 10px; }
|
||||
.security-grid { display: grid; grid-template-columns: minmax(260px, .65fr) minmax(0, 1.35fr); gap: 10px; }
|
||||
.security-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;
|
||||
}
|
||||
.security-panel-header {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #dfdfdf;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
}
|
||||
.security-panel-header span { color: #444444; font-size: 12px; }
|
||||
.security-panel-body { flex: 1 1 auto; min-height: 0; padding: 10px; overflow: auto; }
|
||||
.security-field { display: grid; gap: 4px; font-size: 12px; }
|
||||
.security-input {
|
||||
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;
|
||||
}
|
||||
.security-button { margin-top: 8px; min-width: 100px; height: 24px; padding: 0 8px; font-size: 12px; line-height: 12px; }
|
||||
.security-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;
|
||||
}
|
||||
.security-list { margin: 0; padding-left: 16px; display: grid; gap: 6px; font-size: 12px; }
|
||||
.security-ban-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(260px, .9fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.security-bans-wrap { height: 220px; min-height: 220px; }
|
||||
.security-ip-detail {
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border-top: 1px solid #ffffff;
|
||||
border-left: 1px solid #ffffff;
|
||||
border-right: 1px solid #b0b0b0;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
}
|
||||
.security-ip-detail h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
.security-ip-detail ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.security-bans-body-row.is-selected { background: #c5dcff; }
|
||||
.security-table-wrap {
|
||||
min-height: 280px;
|
||||
height: 320px;
|
||||
overflow: auto;
|
||||
border-top: 2px solid #606060;
|
||||
border-left: 2px solid #606060;
|
||||
border-right: 2px solid #ffffff;
|
||||
border-bottom: 2px solid #ffffff;
|
||||
}
|
||||
.security-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
}
|
||||
.security-table th,
|
||||
.security-table td {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid #e1e1e1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.security-table th { position: sticky; top: 0; background: #dfdfdf; z-index: 2; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.security-grid,
|
||||
.security-ban-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -128,81 +128,3 @@
|
||||
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%; }
|
||||
}
|
||||
|
||||
16
static/js/account-alerts.js
Normal file
16
static/js/account-alerts.js
Normal file
@@ -0,0 +1,16 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const title = document.querySelector("[data-alert-detail-title]");
|
||||
const description = document.querySelector("[data-alert-detail-description]");
|
||||
const metadata = document.querySelector("[data-alert-detail-metadata]");
|
||||
|
||||
document.querySelectorAll("[data-alert-row]").forEach((row) => {
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button, input, a")) return;
|
||||
document.querySelectorAll("[data-alert-row].is-selected").forEach((item) => item.classList.remove("is-selected"));
|
||||
row.classList.add("is-selected");
|
||||
if (title) title.textContent = row.dataset.alertTitle || "";
|
||||
if (description) description.textContent = row.dataset.alertDescription || "";
|
||||
if (metadata) metadata.textContent = row.dataset.alertMetadata || "{}";
|
||||
});
|
||||
});
|
||||
});
|
||||
39
static/js/account-settings.js
Normal file
39
static/js/account-settings.js
Normal file
@@ -0,0 +1,39 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const panel = document.querySelector("[data-settings-import-panel]");
|
||||
const toggle = document.querySelector("[data-settings-import-toggle]");
|
||||
const submit = document.querySelector("[data-settings-import-submit]");
|
||||
const input = document.querySelector("[data-settings-import-json]");
|
||||
const csrf = document.querySelector('input[name="csrf_token"]')?.value || "";
|
||||
|
||||
toggle?.addEventListener("click", () => {
|
||||
if (!panel) return;
|
||||
panel.hidden = !panel.hidden;
|
||||
if (!panel.hidden) input?.focus();
|
||||
});
|
||||
|
||||
submit?.addEventListener("click", async () => {
|
||||
const body = input?.value.trim() || "";
|
||||
if (!body) {
|
||||
window.WarpBoxAccountUI.toast("Paste settings JSON first.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/account/settings/import.json", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
window.WarpBoxAccountUI.toast(payload.error || "Settings import failed.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
window.WarpBoxAccountUI.toast(`Imported ${payload.applied || 0} settings.`, "success");
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
});
|
||||
});
|
||||
258
static/js/account-ui.js
Normal file
258
static/js/account-ui.js
Normal file
@@ -0,0 +1,258 @@
|
||||
window.WarpBoxAccountUI = (() => {
|
||||
let toastTimer = null;
|
||||
let activeConfirmResolve = null;
|
||||
|
||||
function initStickyTaskbar(options = {}) {
|
||||
const taskbar = options.taskbar || document.querySelector(".top-taskbar");
|
||||
if (!taskbar) return;
|
||||
|
||||
const update = () => {
|
||||
taskbar.classList.toggle("is-scrolled", window.scrollY > 2);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
}
|
||||
|
||||
function closeMenus(root = document) {
|
||||
root.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
||||
item.classList.remove("is-open");
|
||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
}
|
||||
|
||||
function openMenu(item) {
|
||||
if (!item) return;
|
||||
closeMenus(item.closest(".menu-bar") || document);
|
||||
item.classList.add("is-open");
|
||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
function initMenus(options = {}) {
|
||||
const root = options.root || document;
|
||||
root.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(".menu-button");
|
||||
if (button) {
|
||||
const item = button.closest(".menu-item");
|
||||
const isOpen = item?.classList.contains("is-open");
|
||||
closeMenus(root);
|
||||
if (!isOpen) openMenu(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.target.closest(".menu-item")) {
|
||||
closeMenus(root);
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelectorAll(".menu-item").forEach((item) => {
|
||||
item.addEventListener("mouseenter", () => {
|
||||
if (!root.querySelector(".menu-item.is-open")) return;
|
||||
openMenu(item);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeMenus(root);
|
||||
});
|
||||
}
|
||||
|
||||
function toast(message, type = "info", options = {}) {
|
||||
if (window.WarpBoxUI?.toast && !options.forceAccountToast) {
|
||||
window.WarpBoxUI.toast(message, type, options);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = options.target || document.querySelector("#account-toast") || document.querySelector("#toast");
|
||||
if (!target) return;
|
||||
|
||||
target.textContent = message;
|
||||
target.classList.remove("toast-info", "toast-success", "toast-warning", "toast-error", "is-visible");
|
||||
target.classList.add(`toast-${type}`, "is-visible");
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => target.classList.remove("is-visible"), options.duration || 2600);
|
||||
}
|
||||
|
||||
function modalElements(options = {}) {
|
||||
return {
|
||||
modal: options.modal || document.querySelector("#account-modal"),
|
||||
title: options.title || document.querySelector("#account-modal-title"),
|
||||
body: options.body || document.querySelector("#account-modal-body"),
|
||||
backdrop: options.backdrop || document.querySelector("#account-modal-backdrop") || document.querySelector("#modal-backdrop"),
|
||||
};
|
||||
}
|
||||
|
||||
function openModal(titleText, html, options = {}) {
|
||||
const parts = modalElements(options);
|
||||
if (!parts.modal || !parts.title || !parts.body) {
|
||||
if (window.WarpBoxUI?.openPopup) {
|
||||
window.WarpBoxUI.openPopup(titleText, html, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
parts.title.textContent = titleText;
|
||||
if (options.text) {
|
||||
parts.body.textContent = html;
|
||||
} else {
|
||||
parts.body.innerHTML = html;
|
||||
}
|
||||
parts.modal.classList.add("is-visible");
|
||||
parts.backdrop?.classList.add("is-visible");
|
||||
parts.modal.querySelector("[data-modal-close]")?.focus();
|
||||
}
|
||||
|
||||
function closeModal(options = {}) {
|
||||
const parts = modalElements(options);
|
||||
parts.modal?.classList.remove("is-visible");
|
||||
parts.backdrop?.classList.remove("is-visible");
|
||||
if (window.WarpBoxUI?.closePopup && !parts.modal) {
|
||||
window.WarpBoxUI.closePopup(options);
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(message, options = {}) {
|
||||
const title = options.title || "Confirm action";
|
||||
const confirmLabel = options.confirmLabel || "OK";
|
||||
const cancelLabel = options.cancelLabel || "Cancel";
|
||||
const html = `
|
||||
<p>${htmlEscape(message)}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="win98-button" type="button" data-confirm-cancel>${htmlEscape(cancelLabel)}</button>
|
||||
<button class="win98-button" type="button" data-confirm-ok>${htmlEscape(confirmLabel)}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const parts = modalElements(options);
|
||||
if (!parts.modal) {
|
||||
return Promise.resolve(window.confirm(message));
|
||||
}
|
||||
|
||||
openModal(title, html, options);
|
||||
return new Promise((resolve) => {
|
||||
activeConfirmResolve = resolve;
|
||||
parts.modal.querySelector("[data-confirm-ok]")?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function finishConfirm(result) {
|
||||
if (activeConfirmResolve) {
|
||||
activeConfirmResolve(result);
|
||||
activeConfirmResolve = null;
|
||||
}
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function setDirtyState(isDirty, options = {}) {
|
||||
const target = options.target || document.querySelector("[data-dirty-chip]");
|
||||
if (!target) return;
|
||||
target.classList.toggle("is-dirty", Boolean(isDirty));
|
||||
target.textContent = isDirty ? (options.dirtyText || "unsaved changes") : (options.cleanText || "");
|
||||
}
|
||||
|
||||
function bindFormDirtyState(form, options = {}) {
|
||||
const targetForm = typeof form === "string" ? document.querySelector(form) : form;
|
||||
if (!targetForm) return;
|
||||
|
||||
let baseline = new FormData(targetForm);
|
||||
const serialize = () => new URLSearchParams(new FormData(targetForm)).toString();
|
||||
let baselineValue = new URLSearchParams(baseline).toString();
|
||||
|
||||
const update = () => setDirtyState(serialize() !== baselineValue, options);
|
||||
targetForm.addEventListener("input", update);
|
||||
targetForm.addEventListener("change", update);
|
||||
targetForm.addEventListener("submit", () => {
|
||||
baseline = new FormData(targetForm);
|
||||
baselineValue = new URLSearchParams(baseline).toString();
|
||||
setDirtyState(false, options);
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
function bindConfirmActions(root = document) {
|
||||
root.addEventListener("click", async (event) => {
|
||||
const ok = event.target.closest("[data-confirm-ok]");
|
||||
if (ok) {
|
||||
finishConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cancel = event.target.closest("[data-confirm-cancel], [data-modal-close]");
|
||||
if (cancel) {
|
||||
finishConfirm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const action = event.target.closest("[data-confirm]");
|
||||
if (!action) return;
|
||||
if (action.dataset.confirmAccepted === "true") {
|
||||
delete action.dataset.confirmAccepted;
|
||||
return;
|
||||
}
|
||||
|
||||
const message = action.getAttribute("data-confirm");
|
||||
if (!message) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const accepted = await confirm(message, {
|
||||
title: action.getAttribute("data-confirm-title") || "Confirm action",
|
||||
confirmLabel: action.getAttribute("data-confirm-label") || "OK",
|
||||
cancelLabel: action.getAttribute("data-cancel-label") || "Cancel",
|
||||
});
|
||||
if (!accepted) return;
|
||||
|
||||
if (action instanceof HTMLAnchorElement && action.href) {
|
||||
window.location.href = action.href;
|
||||
return;
|
||||
}
|
||||
|
||||
const form = action.closest("form");
|
||||
const type = (action.getAttribute("type") || "").toLowerCase();
|
||||
if (form && (type === "submit" || type === "")) {
|
||||
form.requestSubmit(action);
|
||||
return;
|
||||
}
|
||||
|
||||
action.dataset.confirmAccepted = "true";
|
||||
action.click();
|
||||
});
|
||||
}
|
||||
|
||||
function htmlEscape(value) {
|
||||
return String(value || "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function init(root = document) {
|
||||
initStickyTaskbar();
|
||||
initMenus({ root });
|
||||
bindConfirmActions(root);
|
||||
document.querySelector("#account-modal-backdrop")?.addEventListener("click", () => closeModal());
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
initStickyTaskbar,
|
||||
initMenus,
|
||||
toast,
|
||||
confirm,
|
||||
openModal,
|
||||
closeModal,
|
||||
setDirtyState,
|
||||
bindFormDirtyState,
|
||||
closeMenus,
|
||||
};
|
||||
})();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
window.WarpBoxAccountUI.init();
|
||||
});
|
||||
101
static/js/account-user-edit.js
Normal file
101
static/js/account-user-edit.js
Normal file
@@ -0,0 +1,101 @@
|
||||
(function () {
|
||||
const form = document.querySelector('[data-ue-form]');
|
||||
const dirtyIndicator = document.querySelector('[data-ue-dirty]');
|
||||
const menuItems = Array.from(document.querySelectorAll('.menu-item'));
|
||||
const toast = document.getElementById('account-toast');
|
||||
let dirty = false;
|
||||
let toastTimer = null;
|
||||
|
||||
function setDirty(next) {
|
||||
dirty = next;
|
||||
if (dirtyIndicator) {
|
||||
dirtyIndicator.textContent = dirty ? 'Unsaved changes' : 'No unsaved changes';
|
||||
}
|
||||
const chip = document.querySelector('[data-dirty-chip]');
|
||||
if (chip) {
|
||||
chip.textContent = dirty ? '● unsaved' : '';
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
if (!toast) return;
|
||||
toast.textContent = msg;
|
||||
toast.className = 'toast is-visible' + (type ? ' toast-' + type : '');
|
||||
window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(function () {
|
||||
toast.classList.remove('is-visible');
|
||||
}, 2400);
|
||||
}
|
||||
|
||||
function closeMenus() {
|
||||
menuItems.forEach(function (item) { item.classList.remove('is-open'); });
|
||||
}
|
||||
|
||||
menuItems.forEach(function (item) {
|
||||
const btn = item.querySelector('.menu-button');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const open = item.classList.contains('is-open');
|
||||
closeMenus();
|
||||
if (!open) item.classList.add('is-open');
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', closeMenus);
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenus();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
if (form) form.requestSubmit ? form.requestSubmit() : form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('change', function () { setDirty(true); });
|
||||
form.addEventListener('input', function () { setDirty(true); });
|
||||
form.addEventListener('submit', function () { setDirty(false); });
|
||||
}
|
||||
|
||||
document.querySelectorAll('[data-ue-command]').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
closeMenus();
|
||||
const cmd = el.getAttribute('data-ue-command');
|
||||
switch (cmd) {
|
||||
case 'save':
|
||||
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
||||
break;
|
||||
case 'discard':
|
||||
if (dirty && form) {
|
||||
if (window.confirm('Discard unsaved changes?')) {
|
||||
setDirty(false);
|
||||
form.reset();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'reset-password': {
|
||||
const resetForm = document.querySelector('form[action*="/password/reset"]');
|
||||
if (resetForm && window.confirm('Reset this user\'s password? A temporary password will be generated and shown.')) {
|
||||
resetForm.submit();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
showToast('Action: ' + cmd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// sticky scroll shadow on taskbar
|
||||
const header = document.querySelector('.top-taskbar');
|
||||
if (header) {
|
||||
function updateScroll() {
|
||||
header.classList.toggle('is-scrolled', window.scrollY > 4);
|
||||
}
|
||||
updateScroll();
|
||||
window.addEventListener('scroll', updateScroll, { passive: true });
|
||||
}
|
||||
}());
|
||||
67
static/js/account-users.js
Normal file
67
static/js/account-users.js
Normal file
@@ -0,0 +1,67 @@
|
||||
(function () {
|
||||
const masterCheck = document.getElementById('master-check');
|
||||
const rowChecks = document.querySelectorAll('.row-check');
|
||||
const bulkForm = document.getElementById('users-bulk-form');
|
||||
const selectedIdsInput = document.getElementById('bulk-selected-ids');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const focusCreateBtn = document.querySelector('[data-users-action="focus-create"]');
|
||||
const selectVisibleBtns = document.querySelectorAll('[data-users-action="select-visible"]');
|
||||
|
||||
function updateSelected() {
|
||||
const checked = document.querySelectorAll('.row-check:checked');
|
||||
const ids = Array.from(checked).map(cb => cb.value);
|
||||
selectedIdsInput.value = ids.join(',');
|
||||
if (selectedCount) {
|
||||
selectedCount.textContent = ids.length + ' selected';
|
||||
}
|
||||
if (masterCheck) {
|
||||
const allRowChecks = document.querySelectorAll('.row-check');
|
||||
masterCheck.checked = allRowChecks.length > 0 && checked.length === allRowChecks.length;
|
||||
masterCheck.indeterminate = checked.length > 0 && checked.length < allRowChecks.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (masterCheck) {
|
||||
masterCheck.addEventListener('change', function () {
|
||||
document.querySelectorAll('.row-check').forEach(function (cb) {
|
||||
cb.checked = masterCheck.checked;
|
||||
});
|
||||
updateSelected();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
if (event.target.classList.contains('row-check')) {
|
||||
updateSelected();
|
||||
}
|
||||
});
|
||||
|
||||
selectVisibleBtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.row-check').forEach(function (cb) {
|
||||
cb.checked = true;
|
||||
});
|
||||
updateSelected();
|
||||
});
|
||||
});
|
||||
|
||||
if (focusCreateBtn) {
|
||||
focusCreateBtn.addEventListener('click', function () {
|
||||
var usernameInput = document.getElementById('users-username');
|
||||
if (usernameInput) {
|
||||
usernameInput.scrollIntoView({ behavior: 'smooth' });
|
||||
setTimeout(function () { usernameInput.focus(); }, 150);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateSelected();
|
||||
})();
|
||||
|
||||
function setBulkAction(actionUrl) {
|
||||
var form = document.getElementById('users-bulk-form');
|
||||
if (form) {
|
||||
form.action = actionUrl;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const dataNode = document.getElementById("activity-data");
|
||||
const body = document.getElementById("activity-body");
|
||||
const searchInput = document.getElementById("activity-search");
|
||||
const severityFilter = document.getElementById("activity-severity");
|
||||
const kindFilter = document.getElementById("activity-kind");
|
||||
const statusLeft = document.getElementById("activity-status-left");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
if (!dataNode || !body || !searchInput) return;
|
||||
const events = parseData();
|
||||
|
||||
function parseData() {
|
||||
try {
|
||||
return JSON.parse(dataNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||
}
|
||||
|
||||
function renderKindFilter() {
|
||||
const kinds = new Set(events.map((event) => event.kind || "unknown"));
|
||||
Array.from(kinds).sort().forEach((kind) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = kind;
|
||||
option.textContent = kind;
|
||||
kindFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function createdLabel(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "-";
|
||||
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const severity = severityFilter.value;
|
||||
const kind = kindFilter.value;
|
||||
return events.filter((event) => {
|
||||
const haystack = [event.kind, event.message, event.ip, event.path, event.method].join(" ").toLowerCase();
|
||||
const matchesQuery = !query || haystack.includes(query);
|
||||
const matchesSeverity = severity === "all" || event.severity === severity;
|
||||
const matchesKind = kind === "all" || event.kind === kind;
|
||||
return matchesQuery && matchesSeverity && matchesKind;
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = filtered();
|
||||
body.innerHTML = "";
|
||||
rows.forEach((event) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${createdLabel(event.created_at)}</td>
|
||||
<td>${escapeHtml(event.kind || "-")}</td>
|
||||
<td>${escapeHtml(event.severity || "-")}</td>
|
||||
<td>${escapeHtml(event.ip || "-")}</td>
|
||||
<td>${escapeHtml(event.method || "-")}</td>
|
||||
<td title="${escapeHtml(event.path || "-")}">${escapeHtml(event.path || "-")}</td>
|
||||
<td title="${escapeHtml(event.message || "-")}">${escapeHtml(event.message || "-")}</td>
|
||||
`;
|
||||
body.appendChild(row);
|
||||
});
|
||||
statusLeft.textContent = `${rows.length} activity event(s) visible`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
[searchInput, severityFilter, kindFilter].forEach((element) => {
|
||||
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", render);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
menuController.close();
|
||||
if (button.dataset.command === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (button.dataset.command === "export") {
|
||||
const blob = new Blob([JSON.stringify(filtered(), null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `warpbox-activity-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Visible activity exported");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
renderKindFilter();
|
||||
render();
|
||||
})();
|
||||
@@ -1,265 +0,0 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const dataNode = document.getElementById("alerts-data");
|
||||
const alertsBody = document.getElementById("alerts-body");
|
||||
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 selectAll = document.getElementById("select-all");
|
||||
const selectedCountEl = document.getElementById("selected-count");
|
||||
const totalPill = document.getElementById("alerts-total-pill");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
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 (!dataNode || !alertsBody) return;
|
||||
|
||||
const state = {
|
||||
alerts: parseData(),
|
||||
selected: new Set(),
|
||||
activeID: null
|
||||
};
|
||||
|
||||
function parseData() {
|
||||
try {
|
||||
return JSON.parse(dataNode.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||
}
|
||||
|
||||
function createdLabel(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "-";
|
||||
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function allAlerts() {
|
||||
return state.alerts.slice();
|
||||
}
|
||||
|
||||
function filteredAlerts() {
|
||||
const query = searchInput.value.trim().toLowerCase();
|
||||
const severity = severityFilter.value;
|
||||
const status = statusFilter.value;
|
||||
const group = sourceFilter.value;
|
||||
const rows = allAlerts().filter((alert) => {
|
||||
const haystack = [
|
||||
alert.title,
|
||||
alert.message,
|
||||
alert.code,
|
||||
alert.trace,
|
||||
alert.group
|
||||
].join(" ").toLowerCase();
|
||||
const matchesSearch = !query || haystack.includes(query);
|
||||
const matchesSeverity = severity === "all" || alert.severity === severity;
|
||||
const matchesStatus = status === "all" || alert.status === status;
|
||||
const matchesGroup = group === "all" || alert.group === group;
|
||||
return matchesSearch && matchesSeverity && matchesStatus && matchesGroup;
|
||||
});
|
||||
const order = { high: 3, medium: 2, low: 1 };
|
||||
rows.sort((a, b) => {
|
||||
if (sortFilter.value === "severity") return (order[b.severity] || 0) - (order[a.severity] || 0);
|
||||
if (sortFilter.value === "oldest") return String(a.created_at).localeCompare(String(b.created_at));
|
||||
return String(b.created_at).localeCompare(String(a.created_at));
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function ensureActive(rows) {
|
||||
if (rows.length === 0) {
|
||||
state.activeID = null;
|
||||
return null;
|
||||
}
|
||||
const found = rows.find((item) => item.id === state.activeID);
|
||||
if (found) return found;
|
||||
state.activeID = rows[0].id;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = filteredAlerts();
|
||||
alertsBody.innerHTML = "";
|
||||
rows.forEach((alert) => alertsBody.appendChild(buildRow(alert)));
|
||||
const active = ensureActive(rows);
|
||||
if (active) renderDetails(active);
|
||||
renderSummary(rows);
|
||||
syncSelected();
|
||||
syncSelectAll(rows);
|
||||
}
|
||||
|
||||
function buildRow(alert) {
|
||||
const row = document.createElement("tr");
|
||||
if (state.activeID === alert.id) row.classList.add("is-selected");
|
||||
row.innerHTML = `
|
||||
<td><input type="checkbox" class="row-check"${state.selected.has(alert.id) ? " checked" : ""}></td>
|
||||
<td>${escapeHtml(alert.title || "-")}</td>
|
||||
<td><span class="alerts-pill ${escapeHtml(alert.severity || "low")}">${escapeHtml(alert.severity || "low")}</span></td>
|
||||
<td><span class="alerts-pill ${escapeHtml(alert.status || "open")}">${escapeHtml(alert.status || "open")}</span></td>
|
||||
<td>${escapeHtml(alert.code || "-")}</td>
|
||||
<td>${escapeHtml(alert.trace || "-")}</td>
|
||||
<td>${createdLabel(alert.created_at)}</td>
|
||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
||||
`;
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button") || event.target.closest("input")) return;
|
||||
state.activeID = alert.id;
|
||||
render();
|
||||
});
|
||||
row.querySelector(".row-open")?.addEventListener("click", () => {
|
||||
state.activeID = alert.id;
|
||||
render();
|
||||
});
|
||||
row.querySelector(".row-check")?.addEventListener("change", (event) => {
|
||||
if (event.target.checked) state.selected.add(alert.id);
|
||||
else state.selected.delete(alert.id);
|
||||
syncSelected();
|
||||
syncSelectAll(filteredAlerts());
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderDetails(alert) {
|
||||
detailEls.title.textContent = alert.title || "";
|
||||
detailEls.severity.textContent = alert.severity || "";
|
||||
detailEls.status.textContent = alert.status || "";
|
||||
detailEls.code.textContent = alert.code || "";
|
||||
detailEls.trace.textContent = alert.trace || "";
|
||||
detailEls.time.textContent = createdLabel(alert.created_at);
|
||||
detailEls.description.textContent = alert.message || "";
|
||||
detailEls.metadata.textContent = JSON.stringify(alert.meta || {}, null, 2);
|
||||
}
|
||||
|
||||
function renderSummary(rows) {
|
||||
const open = rows.filter((item) => item.status === "open").length;
|
||||
const high = rows.filter((item) => item.severity === "high" && item.status !== "closed").length;
|
||||
const ack = rows.filter((item) => item.status === "acked").length;
|
||||
const closed = rows.filter((item) => item.status === "closed").length;
|
||||
document.querySelector("[data-open-count]").textContent = String(open);
|
||||
document.querySelector("[data-high-count]").textContent = String(high);
|
||||
document.querySelector("[data-ack-count]").textContent = String(ack);
|
||||
document.querySelector("[data-closed-count]").textContent = String(closed);
|
||||
totalPill.textContent = `${rows.length} alerts`;
|
||||
}
|
||||
|
||||
function syncSelected() {
|
||||
selectedCountEl.textContent = `Selected: ${state.selected.size}`;
|
||||
}
|
||||
|
||||
function syncSelectAll(rows) {
|
||||
selectAll.checked = rows.length > 0 && rows.every((alert) => state.selected.has(alert.id));
|
||||
}
|
||||
|
||||
async function postAction(action, ids) {
|
||||
const response = await fetch("/admin/alerts/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ids })
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || "Request failed");
|
||||
state.alerts = payload.alerts || [];
|
||||
}
|
||||
|
||||
async function runAction(action) {
|
||||
const ids = Array.from(state.selected);
|
||||
if (!ids.length && (action === "ack" || action === "close" || action === "delete")) {
|
||||
showToast("Select one or more alerts first", "warning");
|
||||
return;
|
||||
}
|
||||
if (action === "open-only") {
|
||||
statusFilter.value = "open";
|
||||
render();
|
||||
showToast("Showing open alerts only");
|
||||
return;
|
||||
}
|
||||
if (action === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (action === "copy-meta") {
|
||||
const active = allAlerts().find((item) => item.id === state.activeID);
|
||||
if (active) {
|
||||
navigator.clipboard?.writeText(JSON.stringify(active.meta || {}, null, 2)).catch(() => {});
|
||||
}
|
||||
showToast("Metadata copied");
|
||||
return;
|
||||
}
|
||||
if (action === "export") {
|
||||
const blob = new Blob([JSON.stringify(filteredAlerts(), null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `warpbox-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast("Visible alerts exported");
|
||||
return;
|
||||
}
|
||||
if (action === "help-codes") {
|
||||
showToast("Codes map to internal security and service traces.");
|
||||
return;
|
||||
}
|
||||
if (action === "help-meta") {
|
||||
showToast("Metadata shows extra context for each alert.");
|
||||
return;
|
||||
}
|
||||
await postAction(action, ids);
|
||||
state.selected.clear();
|
||||
render();
|
||||
showToast(`Action complete: ${action}`, "success");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render);
|
||||
});
|
||||
|
||||
selectAll?.addEventListener("change", () => {
|
||||
const rows = filteredAlerts();
|
||||
rows.forEach((alert) => {
|
||||
if (selectAll.checked) state.selected.add(alert.id);
|
||||
else state.selected.delete(alert.id);
|
||||
});
|
||||
render();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
try {
|
||||
await runAction(button.dataset.command);
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
await runAction("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
@@ -1,526 +0,0 @@
|
||||
(() => {
|
||||
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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
[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();
|
||||
})();
|
||||
@@ -1,201 +0,0 @@
|
||||
(() => {
|
||||
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();
|
||||
})();
|
||||
@@ -1,272 +0,0 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const eventsNode = document.getElementById("security-events-data");
|
||||
const alertsNode = document.getElementById("security-alerts-data");
|
||||
const bansNode = document.getElementById("security-bans-data");
|
||||
const ipInput = document.getElementById("security-ip-input");
|
||||
const banUntilInput = document.getElementById("security-ban-until");
|
||||
const alertList = document.getElementById("security-alert-list");
|
||||
const activityBody = document.getElementById("security-activity-body");
|
||||
const bansBody = document.getElementById("security-bans-body");
|
||||
const bansCount = document.getElementById("security-bans-count");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
const detail = {
|
||||
ip: document.getElementById("security-detail-ip"),
|
||||
risk: document.getElementById("security-detail-risk"),
|
||||
threat: document.getElementById("security-detail-threat"),
|
||||
geo: document.getElementById("security-detail-geo"),
|
||||
asn: document.getElementById("security-detail-asn"),
|
||||
until: document.getElementById("security-detail-until")
|
||||
};
|
||||
|
||||
if (!eventsNode || !alertsNode || !bansNode) return;
|
||||
const state = {
|
||||
events: parse(eventsNode),
|
||||
alerts: parse(alertsNode),
|
||||
bans: parse(bansNode),
|
||||
selectedIP: ""
|
||||
};
|
||||
|
||||
setDefaultBanUntil();
|
||||
|
||||
function parse(node) {
|
||||
try {
|
||||
return JSON.parse(node.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||
}
|
||||
|
||||
function createdLabel(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "-";
|
||||
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function setDefaultBanUntil() {
|
||||
const base = new Date(Date.now() + 30 * 60 * 1000);
|
||||
const yyyy = String(base.getUTCFullYear());
|
||||
const mm = String(base.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(base.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(base.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(base.getUTCMinutes()).padStart(2, "0");
|
||||
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function toRFC3339FromLocalUTC(datetimeLocalValue) {
|
||||
if (!datetimeLocalValue) return "";
|
||||
const date = new Date(datetimeLocalValue + ":00Z");
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function setSelectedIP(ip) {
|
||||
state.selectedIP = ip || "";
|
||||
if (state.selectedIP) ipInput.value = state.selectedIP;
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderAlerts();
|
||||
renderActivity();
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
alertList.innerHTML = "";
|
||||
state.alerts.slice(0, 12).forEach((alert) => {
|
||||
const entry = document.createElement("li");
|
||||
entry.textContent = `${createdLabel(alert.created_at)} | ${alert.severity || "low"} | ${alert.title || "-"}`;
|
||||
alertList.appendChild(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActivity() {
|
||||
activityBody.innerHTML = "";
|
||||
state.events
|
||||
.filter((event) => String(event.kind || "").startsWith("security") || String(event.kind || "").startsWith("auth.admin"))
|
||||
.slice(0, 60)
|
||||
.forEach((event) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${createdLabel(event.created_at)}</td>
|
||||
<td>${escapeHtml(event.kind || "-")}</td>
|
||||
<td>${escapeHtml(event.severity || "-")}</td>
|
||||
<td>${escapeHtml(event.ip || "-")}</td>
|
||||
<td>${escapeHtml(event.path || "-")}</td>
|
||||
<td title="${escapeHtml(event.message || "-")}">${escapeHtml(event.message || "-")}</td>
|
||||
`;
|
||||
activityBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBans() {
|
||||
bansBody.innerHTML = "";
|
||||
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
|
||||
const ips = new Set();
|
||||
state.events.forEach((event) => {
|
||||
const ip = String(event.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
state.bans.forEach((entry) => {
|
||||
const ip = String(entry.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
const rows = Array.from(ips).sort();
|
||||
rows.forEach((ip) => {
|
||||
const ban = banMap.get(ip) || null;
|
||||
const status = ban ? "banned" : "observed";
|
||||
const row = document.createElement("tr");
|
||||
row.className = "security-bans-body-row";
|
||||
if (ip === state.selectedIP) row.classList.add("is-selected");
|
||||
row.innerHTML = `
|
||||
<td>${escapeHtml(ip || "-")}</td>
|
||||
<td>${status}</td>
|
||||
<td>${ban ? createdLabel(ban.until) : "-"}</td>
|
||||
`;
|
||||
row.addEventListener("click", () => setSelectedIP(ip));
|
||||
bansBody.appendChild(row);
|
||||
});
|
||||
bansCount.textContent = `${state.bans.length} active bans`;
|
||||
}
|
||||
|
||||
function renderIPDetails() {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
detail.ip.textContent = "No IP selected";
|
||||
detail.risk.textContent = "-";
|
||||
detail.threat.textContent = "-";
|
||||
detail.geo.textContent = "Placeholder (geoipfast later)";
|
||||
detail.asn.textContent = "Placeholder";
|
||||
detail.until.textContent = "-";
|
||||
return;
|
||||
}
|
||||
const ban = state.bans.find((entry) => entry.ip === ip);
|
||||
detail.ip.textContent = ip;
|
||||
detail.risk.textContent = ban ? "high" : "medium";
|
||||
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
|
||||
detail.geo.textContent = "Placeholder country/region lookup";
|
||||
detail.asn.textContent = "Placeholder ASN/provider lookup";
|
||||
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned";
|
||||
if (ban && ban.until) {
|
||||
const parsed = new Date(ban.until);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
const yyyy = String(parsed.getUTCFullYear());
|
||||
const mm = String(parsed.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(parsed.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(parsed.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(parsed.getUTCMinutes()).padStart(2, "0");
|
||||
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
async function postAction(action, payload = {}) {
|
||||
const response = await fetch("/admin/security/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ...payload })
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || "Request failed");
|
||||
if (Array.isArray(result.bans)) state.bans = result.bans;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function banIP() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("ban", { ip });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP banned", "success");
|
||||
}
|
||||
|
||||
async function banUntil() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
|
||||
if (!banUntil) {
|
||||
showToast("Set valid expiration date", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("ban_until", { ip, ban_until: banUntil });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP ban expiration updated", "success");
|
||||
}
|
||||
|
||||
async function unbanIP() {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Select or enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("unban", { ip });
|
||||
setSelectedIP("");
|
||||
showToast(payload.message || "IP unbanned", "success");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
try {
|
||||
const command = button.dataset.command;
|
||||
if (command === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-ip") {
|
||||
await banIP();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-until") {
|
||||
await banUntil();
|
||||
return;
|
||||
}
|
||||
if (command === "unban-ip") {
|
||||
await unbanIP();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
} finally {
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipInput.addEventListener("input", () => renderIPDetails());
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
if (state.bans.length > 0) {
|
||||
setSelectedIP(state.bans[0].ip);
|
||||
} else {
|
||||
const firstObserved = state.events.find((event) => String(event.ip || "").trim() !== "");
|
||||
if (firstObserved) setSelectedIP(String(firstObserved.ip || "").trim());
|
||||
}
|
||||
render();
|
||||
})();
|
||||
@@ -1,459 +0,0 @@
|
||||
(() => {
|
||||
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();
|
||||
})();
|
||||
@@ -1,304 +0,0 @@
|
||||
(() => {
|
||||
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();
|
||||
})();
|
||||
@@ -53,46 +53,5 @@ function renderTemplate(template, data = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
return { toast, openPopup, closePopup, htmlEscape, renderTemplate };
|
||||
})();
|
||||
|
||||
182
templates/account_alerts.html
Normal file
182
templates/account_alerts.html
Normal file
@@ -0,0 +1,182 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="account-alerts-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<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" role="menu">
|
||||
<a class="menu-action" href="/account/alerts"><span>R</span><span>Refresh alerts</span><span></span></a>
|
||||
<a class="menu-action" href="/account/alerts/export.json"><span>E</span><span>Export JSON</span><span></span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/alerts?status=open"><span>O</span><span>Open</span><span></span></a>
|
||||
<a class="menu-action" href="/account/alerts?severity=high"><span>H</span><span>High severity</span><span></span></a>
|
||||
<a class="menu-action" href="/account/alerts?sort=severity"><span>S</span><span>Sort by severity</span><span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="alerts-layout account-body-content">
|
||||
<section class="stats-grid" aria-label="Alert statistics">
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
<p class="stat-label">Open</p>
|
||||
<p class="stat-value">{{ .Stats.Open }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">needs attention</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Acknowledged</p>
|
||||
<p class="stat-value">{{ .Stats.Acknowledged }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">seen</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
<p class="stat-label">Closed</p>
|
||||
<p class="stat-value">{{ .Stats.Closed }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">done</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-danger">
|
||||
<p class="stat-label">High</p>
|
||||
<p class="stat-value">{{ .Stats.High }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Medium }} medium</span><span class="stat-note-pill">{{ .Stats.Low }} low</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<form class="alerts-filterbar raised-panel" action="/account/alerts" method="get">
|
||||
<label class="account-form-row">
|
||||
<span>Search</span>
|
||||
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="title, code, trace">
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Severity</span>
|
||||
<select class="account-control" name="severity">
|
||||
<option value="all" {{ if eq .Filters.Severity "all" }}selected{{ end }}>All</option>
|
||||
<option value="low" {{ if eq .Filters.Severity "low" }}selected{{ end }}>Low</option>
|
||||
<option value="medium" {{ if eq .Filters.Severity "medium" }}selected{{ end }}>Medium</option>
|
||||
<option value="high" {{ if eq .Filters.Severity "high" }}selected{{ end }}>High</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Status</span>
|
||||
<select class="account-control" name="status">
|
||||
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
|
||||
<option value="open" {{ if eq .Filters.Status "open" }}selected{{ end }}>Open</option>
|
||||
<option value="acknowledged" {{ if eq .Filters.Status "acknowledged" }}selected{{ end }}>Acknowledged</option>
|
||||
<option value="closed" {{ if eq .Filters.Status "closed" }}selected{{ end }}>Closed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Group</span>
|
||||
<select class="account-control" name="group">
|
||||
<option value="all" {{ if eq .Filters.Group "all" }}selected{{ end }}>All</option>
|
||||
{{ range .Groups }}
|
||||
<option value="{{ . }}" {{ if eq $.Filters.Group . }}selected{{ end }}>{{ . }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Sort</span>
|
||||
<select class="account-control" name="sort">
|
||||
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
|
||||
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
|
||||
<option value="severity" {{ if eq .Filters.Sort "severity" }}selected{{ end }}>Severity</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="win98-button" type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<section class="alerts-workspace">
|
||||
<form class="win98-window section-window" action="/account/alerts/bulk/acknowledge" method="post">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">!</span>
|
||||
<h2>Alert List</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<div class="scroll-panel alerts-table-scroll">
|
||||
<table class="account-table alerts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Select</th>
|
||||
<th>Severity</th>
|
||||
<th>Status</th>
|
||||
<th>Code</th>
|
||||
<th>Title</th>
|
||||
<th>Trace</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Alerts }}
|
||||
<tr data-alert-row data-alert-id="{{ .ID }}" data-alert-title="{{ .Title }}" data-alert-description="{{ .Description }}" data-alert-metadata="{{ .MetadataPretty }}" class="{{ if eq $.SelectedAlert.ID .ID }}is-selected{{ end }}">
|
||||
<td><input type="checkbox" name="alert_ids" value="{{ .ID }}"></td>
|
||||
<td><span class="badge is-{{ .Severity }}">{{ .Severity }}</span></td>
|
||||
<td><span class="badge">{{ .Status }}</span></td>
|
||||
<td>{{ .Code }}</td>
|
||||
<td>{{ .Title }}</td>
|
||||
<td>{{ .Trace }}</td>
|
||||
<td>{{ .CreatedAt }}</td>
|
||||
<td>
|
||||
<div class="box-actions">
|
||||
{{ if $.CanManageAlerts }}
|
||||
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/acknowledge">Ack</button>
|
||||
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/close">Close</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="8">No alerts found.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ if .CanManageAlerts }}
|
||||
<div class="bulk-actions raised-panel">
|
||||
<button class="win98-button" type="submit">Acknowledge selected</button>
|
||||
<button class="win98-button" type="submit" formaction="/account/alerts/bulk/close">Close selected</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside class="alerts-detail sunken-panel" aria-label="Alert details">
|
||||
{{ if .SelectedAlert }}
|
||||
<div>
|
||||
<h2 data-alert-detail-title>{{ .SelectedAlert.Title }}</h2>
|
||||
<p data-alert-detail-description>{{ .SelectedAlert.Description }}</p>
|
||||
</div>
|
||||
<pre class="metadata-pre" data-alert-detail-metadata>{{ .SelectedAlert.MetadataPretty }}</pre>
|
||||
<div class="setting-source">
|
||||
<span class="badge is-{{ .SelectedAlert.Severity }}">{{ .SelectedAlert.Severity }}</span>
|
||||
<span class="badge">{{ .SelectedAlert.Status }}</span>
|
||||
<span class="setting-env">{{ .SelectedAlert.Trace }}</span>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div>
|
||||
<h2 data-alert-detail-title>No alert selected</h2>
|
||||
<p data-alert-detail-description>Select an alert row to inspect metadata.</p>
|
||||
</div>
|
||||
<pre class="metadata-pre" data-alert-detail-metadata>{}</pre>
|
||||
{{ end }}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Alerts status">
|
||||
<span>alerts</span>
|
||||
<span>{{ .Stats.Open }} open</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
151
templates/account_box_manager.html
Normal file
151
templates/account_box_manager.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="box-manager-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<nav class="menu-bar" aria-label="Box manager toolbar">
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/boxes"><span>B</span><span>Back to boxes</span><span></span></a>
|
||||
<a class="menu-action" href="{{ .Box.OpenURL }}"><span>O</span><span>Open shared box</span><span></span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="box-manager-layout account-body-content">
|
||||
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
|
||||
|
||||
<section class="stats-grid" aria-label="Box summary">
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Status</p>
|
||||
<p class="stat-value">{{ .Box.Status }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .Box.Flags }}</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Storage</p>
|
||||
<p class="stat-value">{{ .Box.Storage }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ len .Files }} files</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
<p class="stat-label">Expiration</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .Box.ExpiresAt }}</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
<p class="stat-label">Owner policy</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ if .Policy.CanEditMetadata }}editable{{ else }}locked{{ end }}</span><span class="stat-note-pill">{{ if .Policy.CanExtendExpiry }}refreshable{{ else }}no refresh{{ end }}</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="box-manager-grid">
|
||||
<div class="box-manager-main">
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">I</span><h2 id="box-manager-title">Identity</h2></div></div>
|
||||
<div class="section-body sunken-panel">
|
||||
<p><strong>Box:</strong> {{ .Box.ID }}</p>
|
||||
<p><strong>Owner:</strong> {{ .Box.Owner }}</p>
|
||||
<p><strong>Created:</strong> {{ .Box.CreatedAt }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">S</span><h2>Sharing Rules</h2></div></div>
|
||||
<form class="section-body sunken-panel account-form" action="/account/boxes/{{ .Box.ID }}" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<label><input type="checkbox" name="disable_zip" value="true" {{ if .Box.DisableZip }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> Disable ZIP downloads</label>
|
||||
<label><input type="checkbox" name="one_time_download" value="true" {{ if .Box.OneTimeDownload }}checked{{ end }} {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}> One-time download</label>
|
||||
<button class="win98-button" type="submit" {{ if not .Policy.CanEditSharingRules }}disabled{{ end }}>Save Rules</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar"><div class="win98-titlebar-label"><span class="win98-titlebar-icon">F</span><h2>Files</h2></div></div>
|
||||
<form class="section-body sunken-panel" action="/account/boxes/{{ .Box.ID }}/files/delete" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<div class="scroll-panel files-scroll">
|
||||
<table class="account-table boxes-table">
|
||||
<thead><tr><th>Select</th><th>Name</th><th>Size</th><th>Status</th><th>Download</th></tr></thead>
|
||||
<tbody>
|
||||
{{ range .Files }}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="file_ids" value="{{ .ID }}"></td>
|
||||
<td>{{ .Name }}</td>
|
||||
<td>{{ .Size }}</td>
|
||||
<td>{{ .Status }}</td>
|
||||
<td><a class="tiny-button" href="{{ .Download }}">Open</a></td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="5">No files.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bulk-actions raised-panel">
|
||||
<button class="win98-button" type="submit" data-confirm="Delete selected files permanently?" {{ if not .Policy.CanDeleteFiles }}disabled{{ end }}>Delete Files</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="box-manager-side">
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Expiration</h2>
|
||||
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/extend" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<label class="account-form-row"><span>Extend seconds</span><input class="account-control" name="extend_seconds" value="{{ .Policy.MaxExtensionSeconds }}" inputmode="numeric"></label>
|
||||
<button class="win98-button" type="submit" {{ if not .Policy.CanExtendExpiry }}disabled{{ end }}>Extend</button>
|
||||
</form>
|
||||
<form action="/account/boxes/{{ .Box.ID }}/expire" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit" data-confirm="Expire this box now?" {{ if not .Policy.CanEditMetadata }}disabled{{ end }}>Expire Now</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Password</h2>
|
||||
<form class="account-form" action="/account/boxes/{{ .Box.ID }}/password" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<label class="account-form-row"><span>New password</span><input class="account-control" name="password" type="password" autocomplete="new-password"></label>
|
||||
<button class="win98-button" type="submit" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Set Password</button>
|
||||
</form>
|
||||
<form action="/account/boxes/{{ .Box.ID }}/password/remove" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit" data-confirm="Remove box password?" {{ if not .Policy.CanEditPassword }}disabled{{ end }}>Remove Password</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Resolved Policy</h2>
|
||||
<pre class="metadata-pre policy-pre">{{ .PolicyJSON }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<h2>Box Activity</h2>
|
||||
<div class="activity-list-compact">
|
||||
{{ range .Activity }}
|
||||
<div class="activity-row"><span class="activity-time">{{ .At }}</span><div><p class="activity-title">{{ .Message }}</p><p class="activity-meta">{{ .Actor }}</p></div></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sunken-panel section-body">
|
||||
<form action="/account/boxes/{{ .Box.ID }}/delete" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit" data-confirm="Delete this box permanently?" {{ if not .Policy.CanDeleteBox }}disabled{{ end }}>Delete Box</button>
|
||||
</form>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Box manager status">
|
||||
<span>{{ .Box.ID }}</span>
|
||||
<span>{{ .Box.Status }}</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
174
templates/account_boxes.html
Normal file
174
templates/account_boxes.html
Normal file
@@ -0,0 +1,174 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="account-boxes-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<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" role="menu">
|
||||
<a class="menu-action" href="/account/boxes"><span>R</span><span>Refresh boxes</span><span></span></a>
|
||||
<a class="menu-action" href="/account/boxes/export.csv"><span>E</span><span>Export visible CSV</span><span></span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/boxes?status=active"><span>A</span><span>Active</span><span></span></a>
|
||||
<a class="menu-action" href="/account/boxes?status=expired"><span>X</span><span>Expired</span><span></span></a>
|
||||
<a class="menu-action" href="/account/boxes?sort=largest"><span>L</span><span>Largest first</span><span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="boxes-layout account-body-content">
|
||||
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
|
||||
|
||||
<section class="stats-grid" aria-label="Box statistics">
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Visible</p>
|
||||
<p class="stat-value">{{ .Stats.Visible }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Total }} matching</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
<p class="stat-label">Expired</p>
|
||||
<p class="stat-value">{{ .Stats.Expired }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Storage</p>
|
||||
<p class="stat-value">{{ .Stats.Storage }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
<p class="stat-label">Policy</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .PolicySummary }}</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<form class="boxes-filterbar raised-panel" action="/account/boxes" method="get">
|
||||
<label class="account-form-row">
|
||||
<span>Search</span>
|
||||
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="id, owner, file">
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Owner</span>
|
||||
<input class="account-control" name="owner" value="{{ .Filters.Owner }}" placeholder="all">
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Status</span>
|
||||
<select class="account-control" name="status">
|
||||
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
|
||||
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>Active</option>
|
||||
<option value="pending" {{ if eq .Filters.Status "pending" }}selected{{ end }}>Pending</option>
|
||||
<option value="expired" {{ if eq .Filters.Status "expired" }}selected{{ end }}>Expired</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Flag</span>
|
||||
<select class="account-control" name="flag">
|
||||
<option value="all" {{ if eq .Filters.Flag "all" }}selected{{ end }}>All</option>
|
||||
<option value="password" {{ if eq .Filters.Flag "password" }}selected{{ end }}>Password</option>
|
||||
<option value="one-time" {{ if eq .Filters.Flag "one-time" }}selected{{ end }}>One-time</option>
|
||||
<option value="zip-disabled" {{ if eq .Filters.Flag "zip-disabled" }}selected{{ end }}>ZIP disabled</option>
|
||||
<option value="expired" {{ if eq .Filters.Flag "expired" }}selected{{ end }}>Expired</option>
|
||||
<option value="refreshable" {{ if eq .Filters.Flag "refreshable" }}selected{{ end }}>Refreshable</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="account-form-row">
|
||||
<span>Sort</span>
|
||||
<select class="account-control" name="sort">
|
||||
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
|
||||
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
|
||||
<option value="largest" {{ if eq .Filters.Sort "largest" }}selected{{ end }}>Largest</option>
|
||||
<option value="expires" {{ if eq .Filters.Sort "expires" }}selected{{ end }}>Expires soon</option>
|
||||
<option value="expired" {{ if eq .Filters.Sort "expired" }}selected{{ end }}>Expired first</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="page_size" value="{{ .PageSize }}">
|
||||
<button class="win98-button" type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<form class="win98-window section-window" action="/account/boxes/bulk/expire" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">B</span>
|
||||
<h2>Box Index</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<div class="scroll-panel boxes-table-scroll">
|
||||
<table class="account-table boxes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Select</th>
|
||||
<th>Box</th>
|
||||
<th>Owner</th>
|
||||
<th>Status</th>
|
||||
<th>Files</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Flags</th>
|
||||
<th>Refresh policy</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="box_ids" value="{{ .ID }}"></td>
|
||||
<td>{{ .ID }}</td>
|
||||
<td>{{ .Owner }}</td>
|
||||
<td><span class="badge">{{ .Status }}</span></td>
|
||||
<td>{{ .FileCount }}</td>
|
||||
<td>{{ .Size }}</td>
|
||||
<td>{{ .CreatedAt }}</td>
|
||||
<td>{{ .ExpiresAt }}</td>
|
||||
<td>{{ .Flags }}</td>
|
||||
<td>{{ .Policy }}</td>
|
||||
<td>
|
||||
<div class="box-actions">
|
||||
<a class="tiny-button" href="{{ .OpenURL }}">Open</a>
|
||||
{{ if .CanManage }}<a class="tiny-button" href="{{ .ManageURL }}">Manage</a>{{ end }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="11">No indexed boxes found.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bulk-actions raised-panel">
|
||||
<label class="account-form-row">
|
||||
<span>Bump seconds</span>
|
||||
<input class="account-control" name="bump_seconds" value="3600" inputmode="numeric">
|
||||
</label>
|
||||
<button class="win98-button" type="submit" data-confirm="Expire selected boxes?">Expire selected</button>
|
||||
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/bump-expiry">Bump selected</button>
|
||||
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/delete" data-confirm="Delete selected boxes permanently?">Delete selected</button>
|
||||
<button class="win98-button" type="submit" formaction="/account/boxes/delete-largest" data-confirm="Delete 10 biggest matching boxes permanently?">Delete largest 10</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<nav class="pagination-strip raised-panel" aria-label="Pagination">
|
||||
<span class="badge">Page {{ .Page }} / {{ .TotalPages }}</span>
|
||||
{{ if .HasPrev }}<a class="win98-button" href="{{ .PrevURL }}">Prev</a>{{ end }}
|
||||
{{ if .HasNext }}<a class="win98-button" href="{{ .NextURL }}">Next</a>{{ end }}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Boxes status">
|
||||
<span>boxes index</span>
|
||||
<span>{{ .Total }} matching</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
198
templates/account_dashboard.html
Normal file
198
templates/account_dashboard.html
Normal file
@@ -0,0 +1,198 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="account-dashboard-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<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" role="menu">
|
||||
<a class="menu-action" href="/account"><span>R</span><span>Refresh dashboard</span><span class="shortcut">F5</span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="#alerts"><span>!</span><span>Go to alerts</span><span></span></a>
|
||||
<a class="menu-action" href="#recent-boxes"><span>B</span><span>Go to recent boxes</span><span></span></a>
|
||||
<a class="menu-action" href="#recent-activity"><span>T</span><span>Go to recent activity</span><span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">Tools</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/boxes"><span>B</span><span>Boxes</span><span></span></a>
|
||||
<a class="menu-action" href="/account/alerts"><span>!</span><span>Alerts</span><span></span></a>
|
||||
{{ if .CanManageUsers }}
|
||||
<a class="menu-action" href="/account/users"><span>U</span><span>Users</span><span></span></a>
|
||||
{{ end }}
|
||||
{{ if .CanViewSettings }}
|
||||
<a class="menu-action" href="/account/settings"><span>S</span><span>Settings</span><span></span></a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="account-body-content">
|
||||
<section class="dashboard-hero raised-panel" aria-labelledby="account-dashboard-title">
|
||||
<div class="hero-copy">
|
||||
<h2 id="account-dashboard-title">Dashboard</h2>
|
||||
<p>Account overview for boxes, alerts, storage, users, and recent activity.</p>
|
||||
</div>
|
||||
<div class="hero-status" aria-label="System summary">
|
||||
{{ range .Statuses }}
|
||||
<div class="hero-status-row"><span>{{ .Label }}</span><strong class="status-{{ .Severity }}">{{ .Value }}</strong></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats-grid" aria-label="Dashboard statistics">
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Active boxes</p>
|
||||
<p class="stat-value">{{ .Stats.ActiveBoxes }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">live filesystem scan</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Storage used</p>
|
||||
<p class="stat-value">{{ .Stats.StorageUsedLabel }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">local backend</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
<p class="stat-label">Alerts</p>
|
||||
<p class="stat-value">{{ .Stats.AlertCount }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">alert model pending</span></p>
|
||||
</article>
|
||||
{{ if .ShowUsersStat }}
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
<p class="stat-label">Users</p>
|
||||
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.ActiveUsers }} active</span><span class="stat-note-pill">{{ .Stats.DisabledUsers }} disabled</span></p>
|
||||
</article>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
<section class="main-grid" aria-label="Dashboard panels">
|
||||
<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 Preview</h2>
|
||||
</div>
|
||||
<div class="titlebar-actions">
|
||||
<a class="titlebar-link-button" href="/account/alerts">Show all</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<div class="scroll-panel alerts-scroll">
|
||||
<div class="alert-list">
|
||||
{{ range .Alerts }}
|
||||
<div class="alert-row">
|
||||
<span class="badge is-{{ .Severity }}">{{ .Severity }}</span>
|
||||
<div>
|
||||
<p class="alert-title">{{ .Title }}</p>
|
||||
<p class="alert-desc">{{ .Detail }}</p>
|
||||
</div>
|
||||
<div class="alert-actions">
|
||||
<a class="tiny-button" href="/account/alerts">Open</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="alert-row">
|
||||
<span class="badge is-ok">ok</span>
|
||||
<div><p class="alert-title">No alerts</p><p class="alert-desc">Nothing needs attention.</p></div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="recent-boxes" class="win98-window section-window">
|
||||
<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="/account/boxes">Show all</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<div class="scroll-panel boxes-scroll">
|
||||
<table class="account-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Box</th>
|
||||
<th>Files</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Flags</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .RecentBoxes }}
|
||||
<tr>
|
||||
<td>{{ .ID }}</td>
|
||||
<td>{{ .FileCount }}</td>
|
||||
<td>{{ .TotalSizeLabel }}</td>
|
||||
<td>{{ .CreatedAt }}</td>
|
||||
<td>{{ .ExpiresAt }}</td>
|
||||
<td>{{ .Flags }}</td>
|
||||
<td>
|
||||
<div class="box-actions">
|
||||
<a class="tiny-button" href="/box/{{ .ID }}">Open</a>
|
||||
{{ if .CanManage }}
|
||||
<a class="tiny-button" href="/account/boxes/{{ .ID }}">Manage</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="7">No boxes found.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="recent-activity" class="win98-window section-window span-2">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">T</span>
|
||||
<h2>Recent Activity</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<div class="scroll-panel activity-scroll">
|
||||
<div class="activity-list">
|
||||
{{ range .RecentActivity }}
|
||||
<div class="activity-row">
|
||||
<span class="activity-time">{{ .Time }}</span>
|
||||
<div>
|
||||
<p class="activity-title">{{ .Title }}</p>
|
||||
<p class="activity-meta">{{ .Meta }}</p>
|
||||
</div>
|
||||
<span class="tag info">account</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Dashboard status">
|
||||
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||
<span>{{ if .AccountNav.IsAdmin }}admin{{ else }}account{{ end }}</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
45
templates/account_login.html
Normal file
45
templates/account_login.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ .PageTitle }}</title>
|
||||
{{ template "account_head_assets" . }}
|
||||
</head>
|
||||
<body class="account-body">
|
||||
<div class="app-shell">
|
||||
<div class="app-frame">
|
||||
<main class="account-window" aria-labelledby="account-login-title">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">W</span>
|
||||
<h1 id="account-login-title">WarpBox Account Login</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="account-body-content">
|
||||
{{ if .Error }}
|
||||
<p class="account-error">{{ .Error }}</p>
|
||||
{{ end }}
|
||||
{{ if .AccountLoginEnabled }}
|
||||
<form class="account-form sunken-panel" action="/account/login" method="post">
|
||||
<label class="account-form-row">
|
||||
<span>Username</span>
|
||||
<input name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label class="account-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 class="sunken-panel section-body">Account login is disabled. Set bootstrap admin credentials and restart to enable account access.</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{{ template "account_toast_modal_containers" . }}
|
||||
<script src="/static/js/account-ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
110
templates/account_partials.html
Normal file
110
templates/account_partials.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{{ define "account_head_assets" }}
|
||||
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
|
||||
<link rel="stylesheet" href="/static/css/account.css">
|
||||
{{ end }}
|
||||
|
||||
{{ define "account_shell_start" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ if .PageTitle }}{{ .PageTitle }}{{ else }}WarpBox Account{{ end }}</title>
|
||||
{{ template "account_head_assets" . }}
|
||||
</head>
|
||||
<body class="account-body">
|
||||
<div class="app-shell">
|
||||
<div class="app-frame">
|
||||
{{ template "account_taskbar" . }}
|
||||
{{ end }}
|
||||
|
||||
{{ define "account_shell_end" }}
|
||||
</div>
|
||||
</div>
|
||||
{{ template "account_toast_modal_containers" . }}
|
||||
<script src="/static/js/account-ui.js"></script>
|
||||
{{ range .PageScripts }}
|
||||
<script src="{{ . }}"></script>
|
||||
{{ end }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
|
||||
{{ define "account_taskbar" }}
|
||||
{{ $nav := .AccountNav }}
|
||||
<header class="top-taskbar" aria-label="Account navigation">
|
||||
<a class="start-button" href="/account">
|
||||
<span class="start-logo">W</span>
|
||||
<span>WarpBox</span>
|
||||
</a>
|
||||
|
||||
<nav class="taskbar-nav" aria-label="Primary">
|
||||
<a class="taskbar-button{{ if eq $nav.ActiveSection "dashboard" }} is-active{{ end }}" href="/account">Dashboard</a>
|
||||
{{ if $nav.CanViewBoxes }}
|
||||
<a class="taskbar-button{{ if eq $nav.ActiveSection "boxes" }} is-active{{ end }}" href="/account/boxes">Boxes</a>
|
||||
{{ end }}
|
||||
{{ if $nav.CanViewAlerts }}
|
||||
<a class="taskbar-button{{ if eq $nav.ActiveSection "alerts" }} is-active{{ end }}" href="/account/alerts">Alerts</a>
|
||||
{{ end }}
|
||||
{{ if $nav.CanViewUsers }}
|
||||
<a class="taskbar-button{{ if eq $nav.ActiveSection "users" }} is-active{{ end }}" href="/account/users">Users</a>
|
||||
{{ end }}
|
||||
{{ if $nav.CanViewAPIKeys }}
|
||||
<a class="taskbar-button{{ if eq $nav.ActiveSection "api-keys" }} is-active{{ end }}" href="/account/api-keys">API Keys</a>
|
||||
{{ end }}
|
||||
{{ if $nav.CanViewSettings }}
|
||||
<a class="taskbar-button{{ if eq $nav.ActiveSection "settings" }} is-active{{ end }}" href="/account/settings">Settings</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
|
||||
<div class="taskbar-session" aria-label="Current session summary">
|
||||
{{ if gt $nav.AlertCount 0 }}
|
||||
<a class="alert-chip is-{{ $nav.AlertSeverity }}" href="/account/alerts">! {{ $nav.AlertCount }} alerts</a>
|
||||
{{ else }}
|
||||
<span class="alert-chip is-ok">0 alerts</span>
|
||||
{{ end }}
|
||||
<span class="session-chip">signed in: {{ $nav.Username }}</span>
|
||||
{{ if $nav.IsAdmin }}
|
||||
<span class="session-chip">admin</span>
|
||||
{{ else }}
|
||||
<span class="session-chip">account</span>
|
||||
{{ end }}
|
||||
<span class="dirty-chip" data-dirty-chip></span>
|
||||
</div>
|
||||
</header>
|
||||
{{ end }}
|
||||
|
||||
{{ define "account_window_titlebar" }}
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">{{ if .WindowIcon }}{{ .WindowIcon }}{{ else }}W{{ end }}</span>
|
||||
<h1>{{ if .WindowTitle }}{{ .WindowTitle }}{{ else }}WarpBox Account Control Panel{{ end }}</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>
|
||||
{{ end }}
|
||||
|
||||
{{ define "account_csrf_field" }}
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
{{ end }}
|
||||
|
||||
{{ define "account_toast_modal_containers" }}
|
||||
<div class="toast" id="account-toast" role="status" aria-live="polite"></div>
|
||||
<div class="modal-backdrop" id="account-modal-backdrop" aria-hidden="true"></div>
|
||||
<section class="account-modal win98-window" id="account-modal" role="dialog" aria-modal="true" aria-labelledby="account-modal-title">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">W</span>
|
||||
<h2 id="account-modal-title">WarpBox</h2>
|
||||
</div>
|
||||
<div class="win98-window-controls">
|
||||
<button class="win98-control" type="button" data-modal-close aria-label="Close">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body sunken-panel" id="account-modal-body"></div>
|
||||
</section>
|
||||
{{ end }}
|
||||
134
templates/account_settings.html
Normal file
134
templates/account_settings.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="account-settings-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<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" role="menu">
|
||||
<a class="menu-action" href="/account/settings"><span>R</span><span>Refresh settings</span><span></span></a>
|
||||
<a class="menu-action" href="/account/settings/export.json"><span>E</span><span>Export JSON</span><span></span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
{{ range .Groups }}
|
||||
<a class="menu-action" href="#settings-{{ .Key }}"><span>S</span><span>{{ .Label }}</span><span></span></a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<form class="settings-layout account-body-content" action="/account/settings" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
|
||||
<section class="settings-summary raised-panel" aria-label="Settings status">
|
||||
{{ if .Error }}<span class="badge is-danger">{{ .Error }}</span>{{ end }}
|
||||
{{ if .Notice }}<span class="badge is-ok">{{ .Notice }}</span>{{ end }}
|
||||
{{ if .OverridesAllowed }}
|
||||
<span class="badge is-ok">overrides enabled</span>
|
||||
{{ else }}
|
||||
<span class="badge is-warning">read-only: overrides disabled</span>
|
||||
{{ end }}
|
||||
<a class="tiny-button" href="/account/settings/export.json">Export JSON</a>
|
||||
<button class="tiny-button" type="button" data-settings-import-toggle>Import JSON</button>
|
||||
</section>
|
||||
|
||||
<section class="settings-import raised-panel" data-settings-import-panel hidden>
|
||||
<label class="account-form-row">
|
||||
<span>Settings backup JSON</span>
|
||||
<textarea class="account-control" rows="5" data-settings-import-json></textarea>
|
||||
</label>
|
||||
<button class="win98-button" type="button" data-settings-import-submit {{ if not .CanEdit }}disabled{{ end }}>Import</button>
|
||||
</section>
|
||||
|
||||
<div class="settings-scroll scroll-panel" aria-label="Grouped settings">
|
||||
{{ range .Groups }}
|
||||
<section class="settings-group" id="settings-{{ .Key }}">
|
||||
<header class="settings-group-header">
|
||||
<h2>{{ .Label }}</h2>
|
||||
<p>{{ .Description }}</p>
|
||||
</header>
|
||||
<table class="account-table settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Setting</th>
|
||||
<th>Description</th>
|
||||
<th>Value</th>
|
||||
<th>Source</th>
|
||||
<th>Reset</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ .Label }}</strong>
|
||||
<span class="setting-key">{{ .Key }}</span>
|
||||
</td>
|
||||
<td><p class="setting-description">{{ .Description }}</p></td>
|
||||
<td>
|
||||
{{ if .Editable }}
|
||||
{{ if eq .Type "bool" }}
|
||||
<label class="account-checks"><span><input type="checkbox" name="{{ .Key }}" value="true" {{ if eq .Value "true" }}checked{{ end }}> enabled</span></label>
|
||||
{{ else }}
|
||||
<input class="account-control" name="{{ .Key }}" value="{{ .Value }}" inputmode="numeric">
|
||||
<span class="setting-key">{{ .DisplayValue }}</span>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<span>{{ .DisplayValue }}</span>
|
||||
{{ if .LockedReason }}<span class="setting-key">{{ .LockedReason }}</span>{{ end }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="setting-source">
|
||||
<span class="badge is-info">{{ .Source }}</span>
|
||||
<span class="setting-env">{{ .EnvName }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ if .Editable }}
|
||||
<button class="tiny-button" type="submit" form="reset-{{ .Key }}">Reset</button>
|
||||
{{ else }}
|
||||
<span class="badge">locked</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="5">No settings in this group.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<section class="settings-actions raised-panel" aria-label="Settings actions">
|
||||
<button class="win98-button" type="submit" {{ if not .CanEdit }}disabled{{ end }}>Save Settings</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{{ range .Groups }}
|
||||
{{ range .Rows }}
|
||||
{{ if .Editable }}
|
||||
<form id="reset-{{ .Key }}" action="/account/settings/reset" method="post" hidden>
|
||||
{{ template "account_csrf_field" $ }}
|
||||
<input type="hidden" name="key" value="{{ .Key }}">
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Settings status">
|
||||
<span>settings</span>
|
||||
<span>{{ if .CanEdit }}editable{{ else }}read-only{{ end }}</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
323
templates/account_user_edit.html
Normal file
323
templates/account_user_edit.html
Normal file
@@ -0,0 +1,323 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="user-edit-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<nav class="menu-bar" aria-label="User edit toolbar">
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<button class="menu-action" type="button" data-ue-command="save"><span>💾</span><span>Save user</span><span class="shortcut">Ctrl+S</span></button>
|
||||
<button class="menu-action" type="button" data-ue-command="discard"><span>↩</span><span>Discard changes</span><span class="shortcut">Esc</span></button>
|
||||
{{ if .CanManage }}
|
||||
<div class="menu-separator"></div>
|
||||
{{ if .IsPending }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/invite/resend" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>✉</span><span>Send invite again</span><span></span></button>
|
||||
</form>
|
||||
{{ end }}
|
||||
<button class="menu-action" type="button" data-ue-command="reset-password"><span>🔑</span><span>Reset password</span><span></span></button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">User</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
{{ if .CanManage }}
|
||||
{{ if not .IsSelf }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/enable" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>✔</span><span>Enable user</span><span></span></button>
|
||||
</form>
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/disable" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>⛔</span><span>Disable user</span><span></span></button>
|
||||
</form>
|
||||
<div class="menu-separator"></div>
|
||||
{{ end }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke" style="margin:0">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>◌</span><span>Revoke all sessions</span><span></span></button>
|
||||
</form>
|
||||
{{ end }}
|
||||
<a class="menu-action" href="/account/users"><span>←</span><span>Back to users</span><span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="account-body-content">
|
||||
{{ if .Error }}
|
||||
<div class="account-error-banner">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
{{ if .Success }}
|
||||
<div class="account-success-banner">{{ .Success }}</div>
|
||||
{{ end }}
|
||||
|
||||
<section class="stats-grid" aria-label="User summary">
|
||||
{{ if eq .Status "active" }}
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
{{ else if eq .Status "pending" }}
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
{{ else }}
|
||||
<article class="stat-card sunken-panel is-danger">
|
||||
{{ end }}
|
||||
<p class="stat-label">Status</p>
|
||||
<p class="stat-value">{{ .Status }}</p>
|
||||
<p class="stat-note">
|
||||
{{ if eq .Status "active" }}<span class="stat-note-pill">can sign in</span>
|
||||
{{ else if eq .Status "pending" }}<span class="stat-note-pill">invite not accepted</span>
|
||||
{{ else }}<span class="stat-note-pill">blocked</span>{{ end }}
|
||||
</p>
|
||||
</article>
|
||||
{{ if .IsAdmin }}
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
{{ else }}
|
||||
<article class="stat-card sunken-panel">
|
||||
{{ end }}
|
||||
<p class="stat-label">Role</p>
|
||||
<p class="stat-value">{{ if .IsAdmin }}admin{{ else }}user{{ end }}</p>
|
||||
<p class="stat-note">
|
||||
{{ if .TagNames }}<span class="stat-note-pill">{{ .TagNames }}</span>{{ else }}<span class="stat-note-pill">no tags</span>{{ end }}
|
||||
</p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel">
|
||||
<p class="stat-label">Max file size</p>
|
||||
<p class="stat-value">{{ if .MaxFileSizeStr }}{{ .MaxFileSizeStr }}{{ else }}default{{ end }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">bytes</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel">
|
||||
<p class="stat-label">Max expiry</p>
|
||||
<p class="stat-value">{{ if .MaxExpiryStr }}{{ .MaxExpiryStr }}s{{ else }}default{{ end }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">seconds</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}" id="user-edit-form" data-ue-form>
|
||||
{{ template "account_csrf_field" . }}
|
||||
|
||||
<div class="ue-content-grid">
|
||||
<div class="ue-column">
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">A</span>
|
||||
<h2>Account <span class="ue-panel-sub">identity and basic state</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-form-grid">
|
||||
<div class="ue-field">
|
||||
<label for="ue-username">Username</label>
|
||||
<input class="win98-input" id="ue-username" name="username" type="text" value="{{ .Target.Username }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
|
||||
<span class="ue-help">Visible login name.</span>
|
||||
</div>
|
||||
<div class="ue-field">
|
||||
<label for="ue-email">Email</label>
|
||||
<input class="win98-input" id="ue-email" name="email" type="email" value="{{ .Target.Email }}" {{ if not .CanManage }}disabled{{ end }} autocomplete="off">
|
||||
<span class="ue-help">Account contact and invite destination.</span>
|
||||
</div>
|
||||
{{ if not .IsPending }}
|
||||
<div class="ue-field">
|
||||
<label for="ue-state">State</label>
|
||||
<select class="win98-select" id="ue-state" name="state" {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
|
||||
<option value="active" {{ if eq .Status "active" }}selected{{ end }}>Active</option>
|
||||
<option value="disabled" {{ if eq .Status "disabled" }}selected{{ end }}>Disabled</option>
|
||||
</select>
|
||||
<span class="ue-help">{{ if .IsSelf }}Cannot disable yourself.{{ else }}Account state.{{ end }}</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
<div class="ue-field">
|
||||
<label for="ue-admin-note">Admin note</label>
|
||||
<input class="win98-input" id="ue-admin-note" name="admin_note" type="text" value="{{ .Target.AdminNote }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Private note. Not shown to the user.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">R</span>
|
||||
<h2>Access rights <span class="ue-panel-sub">what this account can do</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-check-grid">
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="upload_allowed" value="1" {{ if index .Check "upload_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Create boxes</strong><span>Allow browser or API box creation.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="manage_own_boxes" value="1" {{ if index .Check "manage_own_boxes" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Manage own boxes</strong><span>Edit sharing, password, or expiry for owned boxes.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="renewable_allowed" value="1" {{ if index .Check "renewable_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Refresh own box expiry</strong><span>Permits time extension within limits.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="zip_download_allowed" value="1" {{ if index .Check "zip_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Use ZIP downloads</strong><span>Allow ZIP generation on this user's boxes.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="one_time_download_allowed" value="1" {{ if index .Check "one_time_download_allowed" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Use one-time boxes</strong><span>Permit one-time ZIP handoff boxes.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="is_admin" value="1" {{ if .IsAdmin }}checked{{ end }} {{ if or (not .CanManage) .IsSelf }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Administrator</strong><span>Grants full admin area access. Last admin is protected.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">L</span>
|
||||
<h2>Limits <span class="ue-panel-sub">0 = unlimited, empty = system default</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-form-grid">
|
||||
<div class="ue-field">
|
||||
<label for="ue-max-file">Max file size (bytes)</label>
|
||||
<input class="win98-input" id="ue-max-file" name="max_file_size_bytes" type="number" min="0"
|
||||
value="{{ .MaxFileSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Per-file cap. Empty = system default.</span>
|
||||
</div>
|
||||
<div class="ue-field">
|
||||
<label for="ue-max-box">Max box size (bytes)</label>
|
||||
<input class="win98-input" id="ue-max-box" name="max_box_size_bytes" type="number" min="0"
|
||||
value="{{ .MaxBoxSizeStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Total size per box. Empty = system default.</span>
|
||||
</div>
|
||||
<div class="ue-field ue-field-full">
|
||||
<label for="ue-max-expiry">Max box expiry (seconds)</label>
|
||||
<input class="win98-input" id="ue-max-expiry" name="max_expiry_seconds" type="number" min="0"
|
||||
value="{{ .MaxExpiryStr }}" {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-help">Maximum expiry when creating or editing a box. Empty = system default.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ue-column">
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">O</span>
|
||||
<h2>Setting overrides <span class="ue-panel-sub">account-specific behavior</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-check-grid ue-check-grid-1col">
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="allow_password_protected" value="1" {{ if index .Check "allow_password_protected" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow password-protected boxes</strong><span>Overrides system default for this account.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="renew_on_access" value="1" {{ if index .Check "renew_on_access" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow renew on access</strong><span>Only applies when the global feature is enabled.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="renew_on_download" value="1" {{ if index .Check "renew_on_download" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow renew on download</strong><span>Only applies when the global feature is enabled.</span></span>
|
||||
</label>
|
||||
<label class="ue-check-card">
|
||||
<input type="checkbox" name="allow_owner_box_editing" value="1" {{ if index .Check "allow_owner_box_editing" }}checked{{ end }} {{ if not .CanManage }}disabled{{ end }}>
|
||||
<span class="ue-check-copy"><strong>Allow owner box editing</strong><span>Lets the user open the box edit page for owned boxes.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">P</span>
|
||||
<h2>Resolved policy <span class="ue-panel-sub">effective permissions after all overrides</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<pre class="ue-policy-pre">{{ .PolicyJSON }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">I</span>
|
||||
<h2>Account info <span class="ue-panel-sub">read-only details</span></h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<ul class="ue-info-list">
|
||||
<li class="ue-info-item"><strong>User ID</strong><span>{{ .Target.ID }}</span></li>
|
||||
<li class="ue-info-item"><strong>Created</strong><span>{{ .CreatedAtStr }}</span></li>
|
||||
<li class="ue-info-item"><strong>Updated</strong><span>{{ .UpdatedAtStr }}</span></li>
|
||||
<li class="ue-info-item"><strong>Tags</strong><span>{{ if .TagNames }}{{ .TagNames }}{{ else }}none{{ end }}</span></li>
|
||||
<li class="ue-info-item"><strong>Password</strong><span>{{ if .IsPending }}pending invite{{ else }}set{{ end }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ if .CanManage }}
|
||||
<section class="win98-window section-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">!</span>
|
||||
<h2>Danger zone</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel ue-panel-body">
|
||||
<div class="ue-danger-row">
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/password/reset">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button ue-danger-btn" type="submit">Reset password</button>
|
||||
</form>
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/sessions/revoke">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button" type="submit">Revoke sessions</button>
|
||||
</form>
|
||||
{{ if not .IsSelf }}
|
||||
<form method="post" action="/account/users/{{ .Target.ID }}/{{ if .Target.Disabled }}enable{{ else }}disable{{ end }}">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="win98-button ue-danger-btn" type="submit">{{ if .Target.Disabled }}Enable{{ else }}Disable{{ end }} user</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ue-footer">
|
||||
<div class="ue-footer-left">
|
||||
<span class="stat-note-pill" data-ue-dirty>No unsaved changes</span>
|
||||
<a class="stat-note-pill" href="/account/users">← Back to users</a>
|
||||
</div>
|
||||
<div class="ue-footer-right">
|
||||
{{ if .CanManage }}
|
||||
<button class="win98-button" type="button" data-ue-command="discard">Discard</button>
|
||||
<button class="win98-button" type="submit">Save user</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="User edit status">
|
||||
<span>editing: {{ .Target.Username }}</span>
|
||||
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||
<span>{{ .Status }}</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
257
templates/account_users.html
Normal file
257
templates/account_users.html
Normal file
@@ -0,0 +1,257 @@
|
||||
{{ template "account_shell_start" . }}
|
||||
<main class="account-window" aria-labelledby="account-users-title">
|
||||
{{ template "account_window_titlebar" . }}
|
||||
|
||||
<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" role="menu">
|
||||
<a class="menu-action" href="/account/users"><span>R</span><span>Refresh list</span><span class="shortcut">F5</span></a>
|
||||
<div class="menu-separator"></div>
|
||||
<form action="/account/logout" method="post">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-item">
|
||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||
<div class="menu-popup" role="menu">
|
||||
<a class="menu-action" href="/account/users?status=active"><span>A</span><span>Show active</span><span></span></a>
|
||||
<a class="menu-action" href="/account/users?status=disabled"><span>D</span><span>Show disabled</span><span></span></a>
|
||||
<a class="menu-action" href="/account/users"><span>X</span><span>Clear filters</span><span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="account-body-content">
|
||||
<section class="dashboard-hero raised-panel" aria-label="Users overview">
|
||||
<div class="hero-copy">
|
||||
<h2 id="account-users-title">WarpBox Users</h2>
|
||||
<p>Accounts, invites, and access. Search, filter, and manage users with safe bulk actions.</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="small-action is-primary" type="button" data-users-action="focus-create">Create / Invite</button>
|
||||
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
|
||||
<button class="small-action" type="button" onclick="location.href='/account/users'">Refresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{ if .Error }}
|
||||
<div class="account-error-banner">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
{{ if .Success }}
|
||||
<div class="account-success-banner">{{ .Success }}</div>
|
||||
{{ end }}
|
||||
|
||||
<section class="stats-grid" aria-label="User statistics">
|
||||
<article class="stat-card sunken-panel is-info">
|
||||
<p class="stat-label">Total users</p>
|
||||
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">all</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-ok">
|
||||
<p class="stat-label">Active</p>
|
||||
<p class="stat-value">{{ .Stats.ActiveUsers }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">enabled</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-warning">
|
||||
<p class="stat-label">Pending invites</p>
|
||||
<p class="stat-value">{{ .Stats.PendingInvites }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">awaiting setup</span></p>
|
||||
</article>
|
||||
<article class="stat-card sunken-panel is-danger">
|
||||
<p class="stat-label">Disabled</p>
|
||||
<p class="stat-value">{{ .Stats.DisabledUsers }}</p>
|
||||
<p class="stat-note"><span class="stat-note-pill">blocked</span></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="main-grid users-grid" aria-label="Users panel and form">
|
||||
<aside class="win98-window section-window users-form-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">+</span>
|
||||
<h2>Create or Invite</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body sunken-panel">
|
||||
<form class="form-grid" method="post" action="/account/users">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<input type="hidden" name="action" value="create">
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-mode">Mode</label>
|
||||
<select class="win98-select" name="mode" id="users-mode">
|
||||
<option value="create">Create local user</option>
|
||||
<option value="invite">Send invite</option>
|
||||
</select>
|
||||
<div class="field-help">Invite creates a disabled account with a setup link. Create makes an active user immediately.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-username">Username</label>
|
||||
<input class="win98-input" name="username" id="users-username" required placeholder="username" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-email">Email</label>
|
||||
<input class="win98-input" name="email" id="users-email" type="email" required placeholder="user@example.test" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-password">Password</label>
|
||||
<input class="win98-input" name="password" id="users-password" type="password" autocomplete="new-password" placeholder="Leave empty for auto-generated">
|
||||
<div class="field-help">If empty, a temporary password will be generated. Never prefill passwords.</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<label for="users-role">Role</label>
|
||||
<select class="win98-select" name="role" id="users-role">
|
||||
<option value="all">No tag (default)</option>
|
||||
{{ range .Tags }}
|
||||
<option value="{{ .Name }}">{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<div class="field-help">Assign an initial role tag. Permissions are resolved from tag settings.</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="small-action" type="reset">Clear</button>
|
||||
<button class="small-action is-primary" type="submit">Apply</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="win98-window section-window span-2 users-table-window">
|
||||
<div class="win98-titlebar">
|
||||
<div class="win98-titlebar-label">
|
||||
<span class="win98-titlebar-icon">U</span>
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="users-filters-bar">
|
||||
<form class="users-filters-form" method="get" action="/account/users" id="users-filters-form">
|
||||
<input class="win98-input" name="q" value="{{ .Filters.Query }}" placeholder="Search username or email">
|
||||
<select class="win98-select" name="status" onchange="this.form.submit()">
|
||||
<option value="" {{ if eq .Filters.Status "" }}selected{{ end }}>all statuses</option>
|
||||
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>active</option>
|
||||
<option value="disabled" {{ if eq .Filters.Status "disabled" }}selected{{ end }}>disabled</option>
|
||||
</select>
|
||||
<select class="win98-select" name="role" onchange="this.form.submit()">
|
||||
<option value="" {{ if eq .Filters.Role "" }}selected{{ end }}>all roles</option>
|
||||
{{ range .Tags }}
|
||||
<option value="{{ .Name }}" {{ if eq $.Filters.Role .Name }}selected{{ end }}>{{ .Name }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<select class="win98-select" name="sort" onchange="this.form.submit()">
|
||||
<option value="username" {{ if eq .Filters.Sort "username" }}selected{{ end }}>sort username</option>
|
||||
<option value="createdDesc" {{ if eq .Filters.Sort "createdDesc" }}selected{{ end }}>newest first</option>
|
||||
</select>
|
||||
<select class="win98-select" name="page_size" onchange="this.form.submit()">
|
||||
<option value="12" {{ if eq .Filters.PageSize 12 }}selected{{ end }}>12 rows</option>
|
||||
<option value="20" {{ if eq .Filters.PageSize 20 }}selected{{ end }}>20 rows</option>
|
||||
<option value="50" {{ if eq .Filters.PageSize 50 }}selected{{ end }}>50 rows</option>
|
||||
</select>
|
||||
<noscript><button class="small-action" type="submit">Filter</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form id="users-bulk-form" method="post" action="/account/users/bulk/disable">
|
||||
{{ template "account_csrf_field" . }}
|
||||
<input type="hidden" name="selected_ids" value="" id="bulk-selected-ids">
|
||||
|
||||
<div class="users-bulk-strip">
|
||||
<button class="small-action" type="button" data-users-action="select-visible">Select visible</button>
|
||||
<button class="small-action" type="submit" data-users-action="bulk-disable" onclick="setBulkAction('/account/users/bulk/disable')">Disable</button>
|
||||
<button class="small-action" type="submit" data-users-action="bulk-enable" onclick="setBulkAction('/account/users/bulk/enable')">Enable</button>
|
||||
<button class="small-action" type="submit" data-users-action="bulk-revoke" onclick="setBulkAction('/account/users/bulk/revoke-sessions')">Revoke sessions</button>
|
||||
<span class="stat-note-pill" id="selected-count">0 selected</span>
|
||||
</div>
|
||||
|
||||
<div class="section-body sunken-panel table-body-panel">
|
||||
<div class="table-scroll">
|
||||
<table class="account-table" aria-label="Users">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-cell"><input type="checkbox" id="master-check" aria-label="Select current page"></th>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
<th>Plan</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Rows }}
|
||||
<tr data-user-id="{{ .ID }}">
|
||||
<td class="check-cell">
|
||||
<input type="checkbox" class="row-check" value="{{ .ID }}" data-user-id="{{ .ID }}" aria-label="Select {{ .Username }}">
|
||||
</td>
|
||||
<td class="user-cell">
|
||||
<div class="user-main">
|
||||
<span class="username">{{ .Username }}{{ if .IsCurrent }} <span class="pill is-info">you</span>{{ end }}</span>
|
||||
<span class="subtle">id: {{ .ID }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="email-cell" title="{{ .Email }}">{{ .Email }}</td>
|
||||
<td>
|
||||
{{ if eq .Status "active" }}
|
||||
<span class="pill is-ok">active</span>
|
||||
{{ else }}
|
||||
<span class="pill is-danger">disabled</span>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td><span class="pill is-info">{{ .Role }}</span></td>
|
||||
<td><span class="pill">{{ .Plan }}</span></td>
|
||||
<td>{{ .CreatedAt }}</td>
|
||||
<td class="actions-cell">
|
||||
<a class="tiny-button" href="/account/users/{{ .ID }}">Edit</a>
|
||||
{{ if and .IsInvite (not .IsCurrent) }}
|
||||
<form method="post" action="/account/users/{{ .ID }}/invite/resend" style="display:inline">
|
||||
{{ template "account_csrf_field" $ }}
|
||||
<button class="tiny-button" type="submit">Resend invite</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ else }}
|
||||
<tr><td colspan="8">No users found.</td></tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="pagination">
|
||||
<span class="pagination-info">
|
||||
Page {{ .Page }} of {{ .TotalPages }} — {{ .Total }} matching user(s)
|
||||
</span>
|
||||
<div class="pagination-controls">
|
||||
{{ if .HasPrev }}
|
||||
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .PrevPage }}">Prev</a>
|
||||
{{ else }}
|
||||
<button class="small-action" disabled>Prev</button>
|
||||
{{ end }}
|
||||
{{ if .HasNext }}
|
||||
<a class="small-action" href="?q={{ .Filters.Query }}&status={{ .Filters.Status }}&role={{ .Filters.Role }}&sort={{ .Filters.Sort }}&page_size={{ .PageSize }}&page={{ .NextPage }}">Next</a>
|
||||
{{ else }}
|
||||
<button class="small-action" disabled>Next</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="win98-statusbar" aria-label="Users status">
|
||||
<span>signed in: {{ .AccountNav.Username }}</span>
|
||||
<span>{{ if .AccountNav.IsAdmin }}admin{{ else }}account{{ end }}</span>
|
||||
<span>ready</span>
|
||||
</footer>
|
||||
</main>
|
||||
{{ template "account_shell_end" . }}
|
||||
40
templates/admin.html
Normal file
40
templates/admin.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!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>
|
||||
@@ -1,92 +0,0 @@
|
||||
{{ define "admin/activity.html" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>WarpBox Admin Activity</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/activity.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 Activity</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="Activity 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>F5</span></button>
|
||||
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible JSON</span><span></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="admin-workspace-body activity-page-body">
|
||||
<section class="activity-panel">
|
||||
<div class="activity-toolbar-grid">
|
||||
<input class="activity-input" id="activity-search" type="search" placeholder="Search kind, message, ip, path">
|
||||
<select class="activity-select" id="activity-severity">
|
||||
<option value="all" selected>All severities</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
<select class="activity-select" id="activity-kind">
|
||||
<option value="all" selected>All kinds</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="activity-table-wrap">
|
||||
<table class="activity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Kind</th>
|
||||
<th>Severity</th>
|
||||
<th>IP</th>
|
||||
<th>Method</th>
|
||||
<th>Path</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="activity-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="status-bar admin-dashboard-statusbar">
|
||||
<span id="activity-status-left">{{ len .Events }} events loaded</span>
|
||||
<span id="activity-status-middle">retention from settings</span>
|
||||
<span id="activity-status-right">admin only</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
<script id="activity-data" type="application/json">{{ toJSON .Events }}</script>
|
||||
<script src="/static/js/warpbox-ui.js"></script>
|
||||
<script src="/static/js/admin/activity.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -1,223 +0,0 @@
|
||||
{{ 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>{{ .OpenCount }}</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>{{ .HighCount }}</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>{{ .AckCount }}</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>{{ .ClosedCount }}</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"></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 user’s 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="delete">Delete selected</button>
|
||||
<button class="win98-button alerts-action-button" type="button" data-command="refresh">Refresh alerts</button>
|
||||
</div>
|
||||
<div class="alerts-mini-note">
|
||||
Alerts persist until deleted. Acknowledge and close update state; delete removes permanently.
|
||||
</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" id="alerts-total-pill">{{ len .Alerts }} 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>
|
||||
<button class="win98-button alerts-footer-button" type="button" data-command="delete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script id="alerts-data" type="application/json">{{ toJSON .Alerts }}</script>
|
||||
<script src="/static/js/warpbox-ui.js"></script>
|
||||
<script src="/static/js/admin/alerts.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -1,245 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,337 +0,0 @@
|
||||
{{ 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 }}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user