13 Commits

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

View File

@@ -0,0 +1,46 @@
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

View File

@@ -1,6 +1,8 @@
# Stage 1: Build # Stage 1: Build
FROM golang:1.23-alpine AS builder FROM golang:1.23-alpine AS builder
ARG APP_VERSION=""
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
WORKDIR /build WORKDIR /build
@@ -16,12 +18,19 @@ COPY static/ static/
COPY templates/ templates/ COPY templates/ templates/
# Build the binary # Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/main.go RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
# Stage 2: Runtime # Stage 2: Runtime
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata ARG APP_VERSION=""
ENV APP_VERSION=${APP_VERSION}
RUN apk add \
--no-cache \
ca-certificates \
tzdata \
wget
# Create non-root user # Create non-root user
RUN addgroup -S warpbox && adduser -S warpbox -G warpbox RUN addgroup -S warpbox && adduser -S warpbox -G warpbox
@@ -50,8 +59,8 @@ ENV WARPBOX_DATA_DIR=/app/data \
WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \ WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS=604800 \
WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \ WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE=false \
WARPBOX_ADMIN_ENABLED=true \ WARPBOX_ADMIN_ENABLED=true \
WARPBOX_GLOBAL_MAX_FILE_SIZE_MB=2048 \ WARPBOX_GLOBAL_MAX_FILE_SIZE_GB=2 \
WARPBOX_GLOBAL_MAX_BOX_SIZE_MB=4096 \ WARPBOX_GLOBAL_MAX_BOX_SIZE_GB=4 \
WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \ WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS=3600 \
WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \ WARPBOX_MAX_GUEST_EXPIRY_SECONDS=172800 \
WARPBOX_BOX_POLL_INTERVAL_MS=5000 \ WARPBOX_BOX_POLL_INTERVAL_MS=5000 \
@@ -60,6 +69,9 @@ ENV WARPBOX_DATA_DIR=/app/data \
EXPOSE 8080 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"] VOLUME ["/app/data"]
CMD ["./warpbox", "run", "--addr", ":8080"] CMD ["./warpbox", "run", "--addr", ":8080"]

View File

@@ -86,8 +86,8 @@ go run ./cmd run --addr :3000
## Configuration ## Configuration
WarpBox loads defaults, applies environment variables at startup, then applies WarpBox loads defaults, applies environment variables at startup, then applies
safe admin settings overrides from BadgerDB. Hard storage and global limit safe admin settings overrides from BadgerDB. Storage path settings remain
settings remain environment controlled. environment controlled.
| Variable | Default | What it does | | Variable | Default | What it does |
| --- | ---: | --- | | --- | ---: | --- |
@@ -108,16 +108,16 @@ settings remain environment controlled.
| `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. | | `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` | `false` | Renews expiring boxes on download. |
| `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. | | `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` | `10` | Default guest retention. |
| `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. | | `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` | `172800` | Max guest retention shown/accepted. |
| `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` | `0` | Hard per-file cap; `0` means unlimited. | | `WARPBOX_GLOBAL_MAX_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_BYTES` | `0` | Hard per-box cap; `0` means unlimited. | | `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_BYTES` | `0` | Default user file cap. | | `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB` | `0` | Default user file cap in GB using `1024^3` conversion. |
| `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` | `0` | Default user box cap. | | `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB` | `0` | Default user box cap in GB using `1024^3` conversion. |
| `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. | | `WARPBOX_SESSION_TTL_SECONDS` | `86400` | Admin session lifetime. |
| `WARPBOX_BOX_POLL_INTERVAL_MS` | `5000` | Browser polling interval for box/file status updates. | | `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_BATCH_SIZE` | `10` | Number of pending thumbnails processed per worker pass. |
| `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. | | `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` | `30` | Delay between thumbnail worker passes. |
Size limits also accept `_MB` variants for the same settings. Legacy `_MB` and `_BYTES` size env names are still accepted for compatibility, but GB env names are the intended format now. GB input uses `1024^3` bytes so UI limits and displayed space stay consistent.
Example: Example:
@@ -189,3 +189,25 @@ keeps most behavior easy to follow from the Go handlers and the small browser
scripts. scripts.
For a short implementation overview, see [docs/tech.md](docs/tech.md). 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 Normal file
View File

@@ -0,0 +1,114 @@
# 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

View File

@@ -151,11 +151,6 @@ func buildAllEnvRows(includeHidden bool) []envRow {
} }
extra := buildExtraEnvRows(includeHidden) extra := buildExtraEnvRows(includeHidden)
if loadErr == nil {
for i := range extra {
extra[i].Default = extra[i].Default
}
}
rows = append(rows, extra...) rows = append(rows, extra...)
return rows return rows
@@ -180,22 +175,6 @@ func buildExtraEnvRows(includeHidden bool) []envRow {
{EnvName: "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", Key: "allow_admin_override", Label: "Allow admin UI to override settings", Type: config.SettingTypeBool, Editable: false, HardLimit: true, Default: "true"}, {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 return extra
} }

View File

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

View File

@@ -150,16 +150,16 @@ Primary environment variables:
- `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED` - `WARPBOX_RENEW_ON_DOWNLOAD_ENABLED`
- `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS` - `WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS`
- `WARPBOX_MAX_GUEST_EXPIRY_SECONDS` - `WARPBOX_MAX_GUEST_EXPIRY_SECONDS`
- `WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES` - `WARPBOX_GLOBAL_MAX_FILE_SIZE_GB`
- `WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES` - `WARPBOX_GLOBAL_MAX_BOX_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES` - `WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB`
- `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES` - `WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB`
- `WARPBOX_SESSION_TTL_SECONDS` - `WARPBOX_SESSION_TTL_SECONDS`
- `WARPBOX_BOX_POLL_INTERVAL_MS` - `WARPBOX_BOX_POLL_INTERVAL_MS`
- `WARPBOX_THUMBNAIL_BATCH_SIZE` - `WARPBOX_THUMBNAIL_BATCH_SIZE`
- `WARPBOX_THUMBNAIL_INTERVAL_SECONDS` - `WARPBOX_THUMBNAIL_INTERVAL_SECONDS`
Size limit settings accept `_MB` or `_BYTES` env names. `WARPBOX_ADMIN_ENABLED` Size limit settings use `_GB` env names with `1024^3` conversion. Legacy `_MB` and `_BYTES` names remain accepted for compatibility. `WARPBOX_ADMIN_ENABLED`
accepts `auto`, `true`, or `false`. accepts `auto`, `true`, or `false`.
The HTTP listen address is configured through the CLI flag: The HTTP listen address is configured through the CLI flag:

18
go.mod
View File

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

33
go.sum
View File

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

116
lib/activity/activity.go Normal file
View File

@@ -0,0 +1,116 @@
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)
}

151
lib/alerts/alerts.go Normal file
View File

@@ -0,0 +1,151 @@
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)
}

View File

@@ -153,6 +153,43 @@ func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) {
manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second) manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second)
return manifest, writeManifestUnlocked(boxID, manifest) 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) { func reconcileManifest(boxID string) (models.BoxManifest, error) {
manifestMu.Lock() manifestMu.Lock()
defer manifestMu.Unlock() defer manifestMu.Unlock()

View File

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

View File

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

View File

@@ -12,14 +12,25 @@ var Definitions = []SettingDefinition{
{Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: 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: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0}, {Key: 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_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: 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_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, 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_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", Label: "Default user max box size GB", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60}, {Key: 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: 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: 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: 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},
} }
func (cfg *Config) SettingRows() []SettingRow { func (cfg *Config) SettingRows() []SettingRow {
@@ -50,6 +61,7 @@ func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
} }
func Definition(key string) (SettingDefinition, bool) { func Definition(key string) (SettingDefinition, bool) {
key = NormalizeLegacySettingKey(key)
for _, def := range Definitions { for _, def := range Definitions {
if def.Key == key { if def.Key == key {
return def, true return def, true
@@ -58,6 +70,35 @@ func Definition(key string) (SettingDefinition, bool) {
return SettingDefinition{}, false 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 { func EditableDefinitions() []SettingDefinition {
defs := make([]SettingDefinition, 0, len(Definitions)) defs := make([]SettingDefinition, 0, len(Definitions))
for _, def := range Definitions { for _, def := range Definitions {

View File

@@ -2,7 +2,6 @@ package config
import ( import (
"fmt" "fmt"
"math"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -27,8 +26,18 @@ func Load() (*Config, error) {
BoxPollIntervalMS: 5000, BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10, ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30, 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), sources: make(map[string]Source),
values: make(map[string]string), values: make(map[string]string),
defaults: make(map[string]string),
} }
// Config precedence: defaults -> env -> overrides. // Config precedence: defaults -> env -> overrides.
@@ -47,6 +56,12 @@ func Load() (*Config, error) {
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil { if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err 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 != "" { if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw)) mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse { if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
@@ -90,6 +105,11 @@ func Load() (*Config, error) {
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, {SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds}, {SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds}, {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},
} }
for _, item := range envInt64s { for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil { if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
@@ -98,17 +118,19 @@ func Load() (*Config, error) {
} }
sizeEnvVars := []struct { sizeEnvVars := []struct {
key string key string
gbName string
mbName string mbName string
bytesName string bytesName string
target *int64 target *int64
}{ }{
{SettingGlobalMaxFileSizeBytes, "WARPBOX_GLOBAL_MAX_FILE_SIZE_MB", "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", &cfg.GlobalMaxFileSizeBytes}, {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_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes}, {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_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes}, {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_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes}, {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},
} }
for _, item := range sizeEnvVars { for _, item := range sizeEnvVars {
if err := cfg.applyMegabytesOrBytesEnv(item.key, item.mbName, item.bytesName, 0, item.target); err != nil { if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
return nil, err return nil, err
} }
} }
@@ -122,6 +144,9 @@ func Load() (*Config, error) {
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS}, {SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize}, {SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds}, {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},
} }
for _, item := range envInts { for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil { if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
@@ -152,25 +177,43 @@ func (cfg *Config) EnsureDirectories() error {
return nil return nil
} }
func (cfg *Config) captureDefaults() { func (cfg *Config) captureDefaults() {
cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault) cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault) cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault) cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault) cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault) cfg.captureDefaultValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled))
cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault) cfg.captureDefaultValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10))
cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault) cfg.captureDefaultValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure))
cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault) cfg.captureDefaultValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled))
cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault) cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled))
cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault) cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10))
cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault) cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10))
cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault) cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxFileSizeBytes))
cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault) cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, formatGigabytesFromBytes(cfg.GlobalMaxBoxSizeBytes))
cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault) cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxFileSizeBytes))
cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault) cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, formatGigabytesFromBytes(cfg.DefaultUserMaxBoxSizeBytes))
cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault) cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10))
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault) cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault) cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault) 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
}
} }
func (cfg *Config) applyStringEnv(key string, name string, target *string) error { func (cfg *Config) applyStringEnv(key string, name string, target *string) error {
@@ -217,14 +260,23 @@ func (cfg *Config) applyInt64Env(key string, name string, min int64, target *int
return nil return nil
} }
func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName string, min int64, target *int64) error { func (cfg *Config) applySizeEnv(key string, gbName string, mbName string, bytesName string, min int64, target *int64) error {
if rawGB := strings.TrimSpace(os.Getenv(gbName)); rawGB != "" {
parsed, err := parseGigabytes(rawGB, float64(min))
if err != nil {
return fmt.Errorf("%s: %w", gbName, err)
}
*target = parsed
cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
return nil
}
if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" { if rawBytes := strings.TrimSpace(os.Getenv(bytesName)); rawBytes != "" {
parsed, err := parseInt64(rawBytes, min) parsed, err := parseInt64(rawBytes, min)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", bytesName, err) return fmt.Errorf("%s: %w", bytesName, err)
} }
*target = parsed *target = parsed
cfg.setValue(key, strconv.FormatInt(parsed, 10), SourceEnv) cfg.setValue(key, formatGigabytesFromBytes(parsed), SourceEnv)
return nil return nil
} }
@@ -236,12 +288,9 @@ func (cfg *Config) applyMegabytesOrBytesEnv(key string, mbName string, bytesName
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", mbName, err) return fmt.Errorf("%s: %w", mbName, err)
} }
if parsedMB > math.MaxInt64/(1024*1024) { parsedBytes := parsedMB * 1000 * 1000
return fmt.Errorf("%s: is too large", mbName)
}
parsedBytes := parsedMB * 1024 * 1024
*target = parsedBytes *target = parsedBytes
cfg.setValue(key, strconv.FormatInt(parsedBytes, 10), SourceEnv) cfg.setValue(key, formatGigabytesFromBytes(parsedBytes), SourceEnv)
return nil return nil
} }

View File

@@ -27,24 +27,36 @@ const (
SettingRenewOnDownloadEnabled = "renew_on_download_enabled" SettingRenewOnDownloadEnabled = "renew_on_download_enabled"
SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds" SettingDefaultGuestExpirySecs = "default_guest_expiry_seconds"
SettingMaxGuestExpirySecs = "max_guest_expiry_seconds" SettingMaxGuestExpirySecs = "max_guest_expiry_seconds"
SettingGlobalMaxFileSizeBytes = "global_max_file_size_bytes" SettingGlobalMaxFileSizeBytes = "global_max_file_size_gb"
SettingGlobalMaxBoxSizeBytes = "global_max_box_size_bytes" SettingGlobalMaxBoxSizeBytes = "global_max_box_size_gb"
SettingDefaultUserMaxFileBytes = "default_user_max_file_size_bytes" SettingDefaultUserMaxFileBytes = "default_user_max_file_size_gb"
SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_bytes" SettingDefaultUserMaxBoxBytes = "default_user_max_box_size_gb"
SettingSessionTTLSeconds = "session_ttl_seconds" SettingSessionTTLSeconds = "session_ttl_seconds"
SettingBoxPollIntervalMS = "box_poll_interval_ms" SettingBoxPollIntervalMS = "box_poll_interval_ms"
SettingThumbnailBatchSize = "thumbnail_batch_size" SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir" 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"
) )
type SettingType string type SettingType string
const ( const (
SettingTypeBool SettingType = "bool" SettingTypeBool SettingType = "bool"
SettingTypeInt64 SettingType = "int64" SettingTypeInt64 SettingType = "int64"
SettingTypeInt SettingType = "int" SettingTypeInt SettingType = "int"
SettingTypeText SettingType = "text" SettingTypeText SettingType = "text"
SettingTypeSizeGB SettingType = "size_gb"
) )
type SettingDefinition struct { type SettingDefinition struct {
@@ -94,7 +106,19 @@ type Config struct {
BoxPollIntervalMS int BoxPollIntervalMS int
ThumbnailBatchSize int ThumbnailBatchSize int
ThumbnailIntervalSeconds 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
sources map[string]Source sources map[string]Source
values map[string]string values map[string]string
defaults map[string]string
} }

View File

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

View File

@@ -39,12 +39,20 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("%s: %w", key, err) return fmt.Errorf("%s: %w", key, err)
} }
cfg.assignInt64(key, parsed, SourceDB) 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: case SettingTypeInt:
parsed64, err := parseInt64(value, def.Minimum) parsed64, err := parseInt64(value, def.Minimum)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", key, err) return fmt.Errorf("%s: %w", key, err)
} }
cfg.assignInt(key, int(parsed64), SourceDB) cfg.assignInt(key, int(parsed64), SourceDB)
case SettingTypeText:
cfg.assignText(key, value, SourceDB)
default: default:
return fmt.Errorf("setting %q is not runtime editable", key) return fmt.Errorf("setting %q is not runtime editable", key)
} }
@@ -76,12 +84,32 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.MaxGuestExpirySeconds = value cfg.MaxGuestExpirySeconds = value
case SettingOneTimeDownloadExpirySecs: case SettingOneTimeDownloadExpirySecs:
cfg.OneTimeDownloadExpirySeconds = value cfg.OneTimeDownloadExpirySeconds = value
case SettingGlobalMaxFileSizeBytes:
cfg.GlobalMaxFileSizeBytes = value
case SettingGlobalMaxBoxSizeBytes:
cfg.GlobalMaxBoxSizeBytes = value
case SettingDefaultUserMaxFileBytes: case SettingDefaultUserMaxFileBytes:
cfg.DefaultUserMaxFileSizeBytes = value cfg.DefaultUserMaxFileSizeBytes = value
case SettingDefaultUserMaxBoxBytes: case SettingDefaultUserMaxBoxBytes:
cfg.DefaultUserMaxBoxSizeBytes = value cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds: case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value 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
} }
cfg.setValue(key, strconv.FormatInt(value, 10), source) cfg.setValue(key, strconv.FormatInt(value, 10), source)
} }
@@ -94,10 +122,26 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
cfg.ThumbnailBatchSize = value cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds: case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value cfg.ThumbnailIntervalSeconds = value
case SettingSecurityLoginMaxAttempts:
cfg.SecurityLoginMaxAttempts = value
case SettingSecurityScanMaxAttempts:
cfg.SecurityScanMaxAttempts = value
case SettingSecurityUploadMaxRequests:
cfg.SecurityUploadMaxRequests = value
} }
cfg.setValue(key, strconv.Itoa(value), source) 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) { func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" { if key == "" {
return return
@@ -113,3 +157,10 @@ func (cfg *Config) sourceFor(key string) Source {
} }
return source return source
} }
func (cfg *Config) DefaultValue(key string) string {
if cfg.defaults == nil {
return ""
}
return cfg.defaults[key]
}

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"math"
"strconv" "strconv"
"strings" "strings"
) )
@@ -39,6 +40,46 @@ func parseInt(value string, min int) (int, error) {
return int(parsed64), nil 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 { func formatBool(value bool) string {
if value { if value {
return "true" return "true"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package routing
import "github.com/gin-gonic/gin" import "github.com/gin-gonic/gin"
type Handlers struct { type Handlers struct {
Health gin.HandlerFunc
Index gin.HandlerFunc Index gin.HandlerFunc
ShowBox gin.HandlerFunc ShowBox gin.HandlerFunc
BoxLogin gin.HandlerFunc BoxLogin gin.HandlerFunc
@@ -16,9 +17,29 @@ type Handlers struct {
FileStatusUpdate gin.HandlerFunc FileStatusUpdate gin.HandlerFunc
DirectBoxUpload gin.HandlerFunc DirectBoxUpload gin.HandlerFunc
LegacyUpload 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) { func Register(router *gin.Engine, handlers Handlers) {
router.GET("/health", handlers.Health)
router.GET("/", handlers.Index) router.GET("/", handlers.Index)
router.GET("/box/:id", handlers.ShowBox) router.GET("/box/:id", handlers.ShowBox)
@@ -36,4 +57,25 @@ func Register(router *gin.Engine, handlers Handlers) {
// Legacy upload routes are kept for compatibility with older clients. // Legacy upload routes are kept for compatibility with older clients.
router.POST("/box/:id/upload", handlers.DirectBoxUpload) router.POST("/box/:id/upload", handlers.DirectBoxUpload)
router.POST("/upload", handlers.LegacyUpload) 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)
} }

217
lib/security/guard.go Normal file
View File

@@ -0,0 +1,217 @@
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
}

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

@@ -0,0 +1,173 @@
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),
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,258 @@
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)
}

View File

@@ -1,58 +1,491 @@
package server package server
import ( import (
"fmt"
"net/http" "net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"warpbox/lib/config" "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) { func (app *App) handleAdminSettings(ctx *gin.Context) {
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) { rows, categories := app.buildAdminSettingsRows()
return ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{
} "AdminUsername": app.config.AdminUsername,
app.renderAdminSettings(ctx, "") "AdminEmail": app.config.AdminEmail,
} "ActivePage": "settings",
"Rows": rows,
func (app *App) handleAdminSettingsPost(ctx *gin.Context) { "Categories": categories,
if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) { "RowsJSON": rows,
return
}
if !app.config.AllowAdminSettingsOverride {
app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.")
return
}
for _, def := range config.EditableDefinitions() {
value := ctx.PostForm(def.Key)
if def.Type == config.SettingTypeBool {
value = "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
}
if err := app.config.ApplyOverride(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
if err := app.store.SetSetting(def.Key, value); err != nil {
app.renderAdminSettings(ctx, err.Error())
return
}
}
applyBoxstoreRuntimeConfig(app.config)
ctx.Redirect(http.StatusSeeOther, "/admin/settings")
}
func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{
"AdminSection": "settings",
"CurrentUser": app.currentAdminUsername(ctx),
"CSRFToken": app.currentCSRFToken(ctx),
"Rows": app.config.SettingRows(),
"OverridesAllowed": app.config.AllowAdminSettingsOverride,
"Error": errorMessage,
}) })
} }
func (app *App) handleAdminSettingsExport(ctx *gin.Context) {
rows, _ := app.buildAdminSettingsRows()
ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows))
}
func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
var request adminSettingsSaveRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
return
}
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
return
}
if currentOverrides == nil {
currentOverrides = map[string]string{}
}
for key, value := range request.Values {
currentOverrides[key] = value
}
rows, warnings, err := app.applySettingsOverrideSet(currentOverrides)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)),
"warnings": warnings,
"rows": rows,
})
}
func (app *App) handleAdminSettingsImport(ctx *gin.Context) {
var request adminSettingsImportRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"})
return
}
values := request.Values
if len(values) == 0 {
values = request.Settings
}
if len(values) == 0 {
values = request.EditableSettings
}
if len(values) == 0 {
values = request.Changes
}
if len(values) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"})
return
}
editable := map[string]bool{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = true
}
filtered := make(map[string]string, len(values))
warnings := make([]string, 0)
for key, value := range values {
if editable[key] {
filtered[key] = value
continue
}
if _, found := config.Definition(key); found {
warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key))
continue
}
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
}
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
return
}
for key, value := range currentOverrides {
if _, exists := filtered[key]; !exists {
filtered[key] = value
}
}
rows, applyWarnings, err := app.applySettingsOverrideSet(filtered)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
warnings = append(warnings, applyWarnings...)
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": fmt.Sprintf("Imported %d setting value(s)", len(values)),
"warnings": warnings,
"rows": rows,
})
}
func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
var request adminSettingsResetRequest
_ = ctx.ShouldBindJSON(&request)
overrideSet, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
return
}
if overrideSet == nil {
overrideSet = map[string]string{}
}
targetKeys := map[string]bool{}
for _, key := range request.Keys {
targetKeys[config.NormalizeLegacySettingKey(key)] = true
}
if len(targetKeys) == 0 {
overrideSet = map[string]string{}
} else {
for key := range targetKeys {
delete(overrideSet, key)
}
}
rows, warnings, err := app.applySettingsOverrideSet(overrideSet)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": true,
"message": "Selected overrides cleared; environment and defaults now apply",
"warnings": warnings,
"rows": rows,
})
}
func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) {
if !app.config.AllowAdminSettingsOverride {
return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment")
}
if values == nil {
values = map[string]string{}
}
overrideSet := make(map[string]string, len(values))
warnings := make([]string, 0)
editable := map[string]config.SettingDefinition{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = def
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
normalizedKey, normalizedValue, err := config.NormalizeOverrideInput(key, strings.TrimSpace(values[key]))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", key, err)
}
key = normalizedKey
value := normalizedValue
def, ok := editable[key]
if !ok {
if _, found := config.Definition(key); found {
return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key)
}
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
continue
}
if value == "" && def.Type != config.SettingTypeText {
return nil, nil, fmt.Errorf("setting %q cannot be blank", key)
}
overrideSet[key] = value
}
nextCfg, err := config.Load()
if err != nil {
return nil, nil, err
}
if err := nextCfg.ApplyOverrides(overrideSet); err != nil {
return nil, nil, err
}
if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil {
return nil, nil, err
}
app.config = nextCfg
applyBoxstoreRuntimeConfig(app.config)
app.reloadSecurityConfig()
rows, _ := app.buildAdminSettingsRows()
return rows, warnings, nil
}
func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse {
settings := make(map[string]string, len(rows))
editable := make(map[string]string)
for _, row := range rows {
settings[row.Key] = row.Value
if row.Editable && !row.Locked {
editable[row.Key] = row.Value
}
}
return adminSettingsExportResponse{
Format: "warpbox.settings.export.v1",
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Settings: settings,
EditableSettings: editable,
Rows: rows,
}
}
func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) {
cfgRows := app.config.SettingRows()
rows := make([]adminSettingRowView, 0, len(cfgRows)+5)
for _, row := range cfgRows {
rows = append(rows, app.makeDefinitionSettingRow(row))
}
rows = append(rows,
app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."),
app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."),
app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."),
app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."),
app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."),
)
sort.Slice(rows, func(i, j int) bool {
if rows[i].Category == rows[j].Category {
return rows[i].Label < rows[j].Label
}
return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category)
})
categoryMeta := settingsCategoryMeta()
categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1)
allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)}
categories = append(categories, allCategory)
grouped := map[string][]adminSettingRowView{}
for _, row := range rows {
grouped[row.Category] = append(grouped[row.Category], row)
}
for _, meta := range categoryMeta {
categories = append(categories, adminSettingsCategoryView{
Key: meta.Key,
Label: meta.Label,
Icon: meta.Icon,
Count: len(grouped[meta.Key]),
Rows: grouped[meta.Key],
})
}
return rows, categories
}
func boolString(value bool) string {
if value {
return "true"
}
return "false"
}
func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView {
def := row.Definition
locked := !def.Editable || def.HardLimit
source := string(row.Source)
sourceBadge := source
if locked {
sourceBadge = "hard env"
}
return adminSettingRowView{
Key: def.Key,
Label: def.Label,
EnvName: def.EnvName,
Category: settingsCategoryForKey(def.Key),
CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)),
Type: string(def.Type),
Value: row.Value,
DefaultValue: app.config.DefaultValue(def.Key),
Source: source,
SourceBadge: sourceBadge,
Editable: def.Editable && !def.HardLimit,
Locked: locked,
HardLimit: def.HardLimit,
Minimum: def.Minimum,
Description: settingsDescription(def.Key),
}
}
func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView {
return adminSettingRowView{
Key: key,
Label: label,
EnvName: envName,
Category: category,
CategoryLabel: settingsCategoryLabel(category),
Type: rowType,
Value: value,
DefaultValue: "",
Source: "environment",
SourceBadge: "hard env",
Editable: false,
Locked: true,
HardLimit: true,
Description: description,
}
}
type settingsCategoryInfo struct {
Key string
Label string
Icon string
}
func settingsCategoryMeta() []settingsCategoryInfo {
return []settingsCategoryInfo{
{Key: "uploads", Label: "Uploads", Icon: "↥"},
{Key: "downloads", Label: "Downloads", Icon: "↧"},
{Key: "retention", Label: "Retention", Icon: "⌛"},
{Key: "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]
}

View File

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

View File

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

View File

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

89
lib/server/ip.go Normal file
View File

@@ -0,0 +1,89 @@
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()
}

View File

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

View File

@@ -1,22 +1,28 @@
package server package server
import ( import (
"fmt" "encoding/json"
"html/template"
"path/filepath"
"time" "time"
"github.com/gin-contrib/gzip" "github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts"
"warpbox/lib/boxstore" "warpbox/lib/boxstore"
"warpbox/lib/config" "warpbox/lib/config"
"warpbox/lib/metastore"
"warpbox/lib/routing" "warpbox/lib/routing"
"warpbox/lib/security"
) )
type App struct { type App struct {
config *config.Config config *config.Config
store *metastore.Store settingsOverridesPath string
adminLoginEnabled bool activityStore *activity.Store
alertStore *alerts.Store
securityGuard *security.Guard
} }
func Run(addr string) error { func Run(addr string) error {
@@ -27,39 +33,37 @@ func Run(addr string) error {
if err := cfg.EnsureDirectories(); err != nil { if err := cfg.EnsureDirectories(); err != nil {
return err return err
} }
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
applyBoxstoreRuntimeConfig(cfg) overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
store, err := metastore.Open(cfg.DBDir)
if err != nil { if err != nil {
return fmt.Errorf("open metadata database: %w", err) return 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 { if err := cfg.ApplyOverrides(overrides); err != nil {
return fmt.Errorf("apply settings overrides: %w", err) return err
} }
applyBoxstoreRuntimeConfig(cfg) applyBoxstoreRuntimeConfig(cfg)
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil {
return fmt.Errorf("bootstrap admin metadata: %w", err)
}
app := &App{ app := &App{
config: cfg, config: cfg,
store: store, settingsOverridesPath: overridesPath,
adminLoginEnabled: bootstrap.AdminLoginEnabled, activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity_log.json")),
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
securityGuard: security.NewGuard(),
} }
app.reloadSecurityConfig()
router := gin.Default() router := gin.Default()
router.LoadHTMLGlob("templates/*.html") router.Use(app.securityMiddleware())
router.NoRoute(app.handleNoRoute)
htmlTemplates, err := loadHTMLTemplates()
if err != nil {
return err
}
router.SetHTMLTemplate(htmlTemplates)
routing.Register(router, routing.Handlers{ routing.Register(router, routing.Handlers{
Health: app.handleHealth,
Index: app.handleIndex, Index: app.handleIndex,
ShowBox: app.handleShowBox, ShowBox: app.handleShowBox,
BoxLogin: handleBoxLogin, BoxLogin: handleBoxLogin,
@@ -73,8 +77,26 @@ func Run(addr string) error {
FileStatusUpdate: app.handleFileStatusUpdate, FileStatusUpdate: app.handleFileStatusUpdate,
DirectBoxUpload: app.handleDirectBoxUpload, DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload, 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.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static") compressed.Static("/static", "./static")
@@ -84,7 +106,37 @@ func Run(addr string) error {
return router.Run(addr) 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) { func applyBoxstoreRuntimeConfig(cfg *config.Config) {
boxstore.SetUploadRoot(cfg.UploadsDir) boxstore.SetUploadRoot(cfg.UploadsDir)
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds) boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
} }
func (app *App) handleHealth(c *gin.Context) {
c.JSON(200, gin.H{
"status": "healthy",
})
}

View File

@@ -39,6 +39,13 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
totalSize := int64(0)
for _, file := range request.Files {
totalSize += file.Size
}
if !app.enforceUploadRateLimit(ctx, totalSize) {
return
}
files, err := boxstore.CreateManifest(boxID, request) files, err := boxstore.CreateManifest(boxID, request)
if err != nil { if err != nil {
@@ -73,6 +80,10 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if !app.enforceUploadRateLimit(ctx, file.Size) {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file) savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil { if err != nil {
@@ -141,6 +152,9 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if !app.enforceUploadRateLimit(ctx, file.Size) {
return
}
savedFile, err := boxstore.SaveUpload(boxID, file) savedFile, err := boxstore.SaveUpload(boxID, file)
if err != nil { if err != nil {
@@ -180,6 +194,9 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
if !app.enforceUploadRateLimit(ctx, totalSize) {
return
}
boxID, err := boxstore.NewBoxID() boxID, err := boxstore.NewBoxID()
if err != nil { if err != nil {

View File

@@ -3,6 +3,7 @@ package server
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -153,3 +154,36 @@ func (app *App) maxRequestBodyBytes() int64 {
} }
return limit + 10*1024*1024 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
View File

@@ -15,9 +15,9 @@ export WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS="${WARPBOX_ONE_TIME_DOWNLOAD_EXP
export WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE="${WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE:-false}" 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. # Storage and expiry limits used by the upload UI and backend validators.
# Use megabytes here; WarpBox converts these to bytes internally. # Use decimal gigabytes here. Examples: 2, 4, 0.5
export WARPBOX_GLOBAL_MAX_FILE_SIZE_MB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_MB:-2048}" # 2 GiB export WARPBOX_GLOBAL_MAX_FILE_SIZE_GB="${WARPBOX_GLOBAL_MAX_FILE_SIZE_GB:-2}" # 2 GB
export WARPBOX_GLOBAL_MAX_BOX_SIZE_MB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_MB:-4096}" # 4 GiB export WARPBOX_GLOBAL_MAX_BOX_SIZE_GB="${WARPBOX_GLOBAL_MAX_BOX_SIZE_GB:-4}" # 4 GB
export WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS="${WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS:-3600}" # 1 hour export WARPBOX_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 export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172800}" # 48 hours
@@ -25,6 +25,17 @@ 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_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}" export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}" 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. # Data location.
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}" export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"

63
static/css/activity.css Normal file
View File

@@ -0,0 +1,63 @@
.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;
}

View File

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

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

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

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

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

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

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

115
static/css/security.css Normal file
View File

@@ -0,0 +1,115 @@
.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;
}
}

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

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

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

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

View File

@@ -128,3 +128,81 @@
font-size: 13px; font-size: 13px;
line-height: 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%; }
}

104
static/js/admin/activity.js Normal file
View File

@@ -0,0 +1,104 @@
(() => {
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();
})();

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

@@ -0,0 +1,265 @@
(() => {
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();
})();

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

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

View File

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

272
static/js/admin/security.js Normal file
View File

@@ -0,0 +1,272 @@
(() => {
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();
})();

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
{{ 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 }}

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

@@ -0,0 +1,223 @@
{{ 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 users permissions, tags, or groups.
</div>
</div>
</section>
<section class="alerts-panel">
<div class="alerts-panel-header">
<div class="alerts-panel-title">Metadata <span class="alerts-panel-sub">simple JSON preview</span></div>
<div class="alerts-panel-tools">
<button class="win98-button alerts-tool-button" type="button" data-command="copy-meta">Copy</button>
</div>
</div>
<div class="alerts-panel-body">
<pre class="alerts-json-box" id="detail-metadata">{
"connector": "local-main",
"mode": "read_only",
"retry_in": "30s"
}</pre>
</div>
</section>
<section class="alerts-panel alerts-actions-panel">
<div class="alerts-panel-header">
<div class="alerts-panel-title">Actions <span class="alerts-panel-sub">simple first version</span></div>
</div>
<div class="alerts-panel-body">
<div class="alerts-action-stack">
<button class="win98-button alerts-action-button" type="button" data-command="ack">Acknowledge selected</button>
<button class="win98-button alerts-action-button" type="button" data-command="close">Close selected</button>
<button class="win98-button alerts-action-button" type="button" data-command="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 }}

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,138 @@
{{ define "admin/security.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Security</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/security.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 Security</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="Security toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Security</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="ban-ip"><span>B</span><span>Ban IP now</span><span></span></button>
<button class="menu-action" type="button" data-command="ban-until"><span>T</span><span>Set ban expiration</span><span></span></button>
<button class="menu-action" type="button" data-command="unban-ip"><span>U</span><span>Unban selected IP</span><span></span></button>
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh data</span><span>F5</span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body security-page-body">
<section class="security-grid">
<section class="security-panel">
<div class="security-panel-header"><strong>Manual controls</strong><span>basic first version</span></div>
<div class="security-panel-body">
<label class="security-field">IP address
<input class="security-input" id="security-ip-input" type="text" placeholder="203.0.113.12">
</label>
<button class="win98-button security-button" type="button" data-command="ban-ip">Ban IP (temporary)</button>
<label class="security-field">Ban expires (UTC)
<input class="security-input" id="security-ban-until" type="datetime-local">
</label>
<button class="win98-button security-button" type="button" data-command="ban-until">Set ban expiration</button>
<button class="win98-button security-button" type="button" data-command="unban-ip">Unban selected IP</button>
<div class="security-note">Ban duration and auto-ban thresholds come from Settings -> Security.</div>
</div>
</section>
<section class="security-panel">
<div class="security-panel-header"><strong>Recent alerts</strong><span>{{ len .Alerts }} total</span></div>
<div class="security-panel-body">
<ul class="security-list" id="security-alert-list"></ul>
</div>
</section>
</section>
<section class="security-panel">
<div class="security-panel-header"><strong>IP addresses</strong><span id="security-bans-count">{{ len .Bans }} active bans</span></div>
<div class="security-panel-body security-ban-grid">
<div class="security-table-wrap security-bans-wrap">
<table class="security-table">
<thead>
<tr>
<th>IP</th>
<th>Status</th>
<th>Ban expires (UTC)</th>
</tr>
</thead>
<tbody id="security-bans-body"></tbody>
</table>
</div>
<div class="security-ip-detail">
<h3 id="security-detail-ip">No IP selected</h3>
<ul>
<li><strong>Risk:</strong> <span id="security-detail-risk">-</span></li>
<li><strong>Threat:</strong> <span id="security-detail-threat">-</span></li>
<li><strong>Geo:</strong> <span id="security-detail-geo">Placeholder (geoipfast later)</span></li>
<li><strong>ASN:</strong> <span id="security-detail-asn">Placeholder</span></li>
<li><strong>Ban until:</strong> <span id="security-detail-until">-</span></li>
</ul>
</div>
</div>
</section>
<section class="security-panel">
<div class="security-panel-header"><strong>Recent security activity</strong><span>{{ len .Events }} rows</span></div>
<div class="security-panel-body">
<div class="security-table-wrap">
<table class="security-table">
<thead>
<tr>
<th>Time</th>
<th>Kind</th>
<th>Severity</th>
<th>IP</th>
<th>Path</th>
<th>Message</th>
</tr>
</thead>
<tbody id="security-activity-body"></tbody>
</table>
</div>
</div>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="security-status-left">Security controls active</span>
<span id="security-status-middle">alerts + activity linked</span>
<span id="security-status-right">admin only</span>
</footer>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script id="security-events-data" type="application/json">{{ toJSON .Events }}</script>
<script id="security-alerts-data" type="application/json">{{ toJSON .Alerts }}</script>
<script id="security-bans-data" type="application/json">{{ toJSON .Bans }}</script>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/security.js"></script>
</body>
</html>
{{ end }}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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