13 Commits

Author SHA1 Message Date
f0dcdd50ca feat: bypass security for health checks and support HEAD downloads
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 2m30s
- Allow the `/health` endpoint to bypass the security middleware, ensuring container health checks succeed even if the proxy IP is banned.
- Add a test to verify health checks from banned IPs.
- Register a HEAD route for file downloads.
- Refactor admin alert status checks to use a new `isUnacknowledgedAlert` helper.
- Update the security runbook documentation with clearer instructions and examples for trusted proxy configuration.
2026-05-23 19:07:11 +03:00
a2c80ac105 feat(admin): make dashboard live and disk-aware
Wire dashboard panels to real alerts, activity, boxes, and users data instead of static mock rows.

Enable working dashboard actions (close alerts, close low alerts, cleanup expired boxes, exports, and navigation).

Update storage overview to use real filesystem free/total space from the uploads volume.

Make top alert chip data-driven across admin pages.
2026-05-04 10:54:44 +03:00
d7cbba1bf2 feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
2026-05-04 02:27:36 +03:00
dc379ea6a6 fix(versioning): Removed v pre-pending 2026-05-04 00:52:34 +03:00
2c9fc03a61 fix
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m42s
2026-05-04 00:45:03 +03:00
0bdf11d3a7 patch(version): Implemented version reporting for the app
Some checks failed
Build and Publish Docker Image / deploy (push) Has been cancelled
2026-05-04 00:40:02 +03:00
bcdcce8fbd feat(env): Added production and development environment
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
2026-05-04 00:33:18 +03:00
fbeff3f6c0 feat/security
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
Reviewed-on: #2
2026-05-04 00:00:36 +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
65 changed files with 5950 additions and 775 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
FROM golang:1.23-alpine AS builder
ARG APP_VERSION=""
RUN apk add --no-cache git ca-certificates
WORKDIR /build
@@ -16,12 +18,19 @@ COPY static/ static/
COPY templates/ templates/
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/main.go
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
# Stage 2: Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
ARG APP_VERSION=""
ENV APP_VERSION=${APP_VERSION}
RUN apk add \
--no-cache \
ca-certificates \
tzdata \
wget
# Create non-root user
RUN addgroup -S warpbox && adduser -S warpbox -G warpbox
@@ -60,6 +69,9 @@ ENV WARPBOX_DATA_DIR=/app/data \
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1
VOLUME ["/app/data"]
CMD ["./warpbox", "run", "--addr", ":8080"]

4
NOTICE
View File

@@ -1,4 +0,0 @@
WarpBox
Copyright (c) 2026 Daniel Legt
This product includes software developed by Daniel Legt.

View File

@@ -189,3 +189,25 @@ keeps most behavior easy to follow from the Go handlers and the small browser
scripts.
For a short implementation overview, see [docs/tech.md](docs/tech.md).
## Docker / Podman
If you are using Podman, please pay attention in the [docker-compose.yml](./docker-compose.example.yml) example
file that has been provided, there's comments in regards to differences between the two.
When it comes to building the image, please make sure that you basically set the `--format docker` in the podman
build command, otherwise it won't have HealthChecks and other issues might arise.
Tip: Put the following in `~/.config/containers/containers.conf`
```toml
[engine]
image_default_format = "docker"
```
For just running the docker-compose.yml with docker image format:
```bash
BUILDAH_FORMAT=docker podman compose up --build
```

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

@@ -1,2 +0,0 @@
The name "WarpBox" and associated branding are not licensed under the Apache License 2.0.
You may not use them without permission.

View File

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

19
docs/geoip-guide.md Normal file
View File

@@ -0,0 +1,19 @@
# GeoIP Guide (Planning)
This project intentionally does not enable GeoIP enforcement yet.
Planned integration target: `github.com/rabuchaim/geoip2fast`.
## Recommended approach
1. Load one shared GeoIP provider instance at startup.
2. Add a small in-memory cache keyed by IP with TTL.
3. Apply lookup timeout and fallback to `unknown` values on failures.
4. Use results first in the admin security detail pane.
5. Add aggregated statistics only after detail pane behavior is stable.
## Why this is safe
- No request path should fail because GeoIP lookup fails.
- Lookup cost stays bounded with caching.
- Security decisions remain independent from GeoIP quality.

40
docs/security-runbook.md Normal file
View File

@@ -0,0 +1,40 @@
# Security Runbook
## Trusted Proxy Setup (Caddy)
Set `WARPBOX_TRUSTED_PROXY_CIDRS` to only the CIDRs of your reverse proxies/load balancers. Without this, WarpBox intentionally ignores forwarding headers and every request may appear to come from the proxy/container bridge, such as `172.30.0.1`.
Example:
```bash
WARPBOX_TRUSTED_PROXY_CIDRS=172.30.0.1/32
```
Caddy example:
```caddyfile
:443 {
reverse_proxy warpbox:8080 {
header_up X-Forwarded-For {http.request.remote.host}
header_up X-Real-IP {http.request.remote.host}
}
}
```
WarpBox will trust `X-Forwarded-For` only if the direct remote IP is inside `WARPBOX_TRUSTED_PROXY_CIDRS`. Prefer the exact proxy IP as a `/32` when it is stable. If Caddy is on a changing Docker/Podman network, use that network's CIDR instead. You can find it with `docker network inspect <network>` or `podman network inspect <network>`.
## IP Ban Operations
- Use temporary bans by default.
- Use `ban_until` only for active incidents requiring explicit windows.
- Before unbanning, inspect related activity and alerts for repeated abuse patterns.
- For destructive actions (`bulk_unban`, `unban_all`), require explicit confirmation.
## Tuning Guidance
- Low traffic deployments: reduce max-attempt thresholds to catch abuse faster.
- High traffic deployments: increase windows and max-attempts incrementally to reduce false positives.
- Watch for:
- repeated `auth.admin.failed`
- repeated `security.scan`
- frequent `security.upload_limit`

26
go.mod
View File

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

53
go.sum
View File

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

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)
}

60
lib/boxstore/cleanup.go Normal file
View File

@@ -0,0 +1,60 @@
package boxstore
import (
"fmt"
"os"
)
type CleanupExpiredResult struct {
Scanned int
Deleted int
Skipped int
DeletedIDs []string
Warnings []string
}
func CleanupExpiredBoxes() (CleanupExpiredResult, error) {
entries, err := os.ReadDir(uploadRoot)
if err != nil {
if os.IsNotExist(err) {
return CleanupExpiredResult{}, nil
}
return CleanupExpiredResult{}, err
}
result := CleanupExpiredResult{
DeletedIDs: make([]string, 0),
Warnings: make([]string, 0),
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
boxID := entry.Name()
if !ValidBoxID(boxID) {
continue
}
result.Scanned++
manifest, err := ReadManifest(boxID)
if err != nil {
result.Skipped++
if !os.IsNotExist(err) {
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err))
}
continue
}
if !IsExpired(manifest) {
continue
}
if err := DeleteBox(boxID); err != nil {
result.Skipped++
result.Warnings = append(result.Warnings, fmt.Sprintf("%s: %v", boxID, err))
continue
}
result.Deleted++
result.DeletedIDs = append(result.DeletedIDs, boxID)
}
return result, nil
}

View File

@@ -0,0 +1,58 @@
package boxstore
import (
"os"
"path/filepath"
"testing"
"time"
"warpbox/lib/models"
)
func TestCleanupExpiredBoxesDeletesOnlyExpiredManifestBoxes(t *testing.T) {
root := filepath.Join(t.TempDir(), "uploads")
previousRoot := UploadRoot()
t.Cleanup(func() { SetUploadRoot(previousRoot) })
SetUploadRoot(root)
expiredID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
activeID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
legacyID := "cccccccccccccccccccccccccccccccc"
if err := os.MkdirAll(BoxPath(expiredID), 0755); err != nil {
t.Fatalf("mkdir expired: %v", err)
}
if err := os.MkdirAll(BoxPath(activeID), 0755); err != nil {
t.Fatalf("mkdir active: %v", err)
}
if err := os.MkdirAll(BoxPath(legacyID), 0755); err != nil {
t.Fatalf("mkdir legacy: %v", err)
}
if err := WriteManifest(expiredID, models.BoxManifest{CreatedAt: time.Now().UTC().Add(-2 * time.Hour), ExpiresAt: time.Now().UTC().Add(-time.Minute)}); err != nil {
t.Fatalf("write expired manifest: %v", err)
}
if err := WriteManifest(activeID, models.BoxManifest{CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(time.Hour)}); err != nil {
t.Fatalf("write active manifest: %v", err)
}
result, err := CleanupExpiredBoxes()
if err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if result.Deleted != 1 {
t.Fatalf("expected 1 deleted box, got %d", result.Deleted)
}
if len(result.DeletedIDs) != 1 || result.DeletedIDs[0] != expiredID {
t.Fatalf("expected deleted id %s, got %#v", expiredID, result.DeletedIDs)
}
if _, err := os.Stat(BoxPath(expiredID)); !os.IsNotExist(err) {
t.Fatalf("expected expired box dir removed, stat err=%v", err)
}
if _, err := os.Stat(BoxPath(activeID)); err != nil {
t.Fatalf("expected active box to remain, stat err=%v", err)
}
if _, err := os.Stat(BoxPath(legacyID)); err != nil {
t.Fatalf("expected legacy box to remain, stat err=%v", err)
}
}

View File

@@ -22,9 +22,15 @@ func TestDefaults(t *testing.T) {
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
t.Fatal("expected default guest/API/download toggles to be enabled")
}
if !cfg.SecurityEnabled {
t.Fatal("expected security features to be enabled by default")
}
if cfg.AdminUsername != "admin" {
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
}
if cfg.Environment != AppEnvironmentDevelopment {
t.Fatalf("unexpected default environment: %s", cfg.Environment)
}
if cfg.AdminPassword != "" {
t.Fatal("expected default admin password to be empty")
}
@@ -39,6 +45,8 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
t.Setenv("WARPBOX_SECURITY_ENABLED", "false")
t.Setenv("WARPBOX_ENV", "production")
cfg, err := Load()
if err != nil {
@@ -63,9 +71,15 @@ func TestEnvironmentOverrides(t *testing.T) {
if !cfg.OneTimeDownloadRetryOnFailure {
t.Fatal("expected one-time retry-on-failure env override to be applied")
}
if cfg.SecurityEnabled {
t.Fatal("expected security features toggle from environment to be applied")
}
if cfg.Source(SettingAPIEnabled) != SourceEnv {
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
}
if cfg.Environment != AppEnvironmentProduction {
t.Fatalf("expected environment override to be production, got %s", cfg.Environment)
}
}
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
@@ -113,6 +127,12 @@ func TestInvalidEnvironmentValues(t *testing.T) {
if _, err := Load(); err == nil {
t.Fatal("expected invalid boolean to fail")
}
clearConfigEnv(t)
t.Setenv("WARPBOX_ENV", "staging")
if _, err := Load(); err == nil {
t.Fatal("expected invalid environment mode to fail")
}
}
func TestSettingsOverridePrecedence(t *testing.T) {
@@ -163,6 +183,7 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ENV",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
@@ -191,6 +212,8 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"WARPBOX_SECURITY_ENABLED",
"WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}

View File

@@ -2,6 +2,7 @@ package config
var Definitions = []SettingDefinition{
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
{Key: SettingEnvironment, EnvName: "WARPBOX_ENV", Label: "Environment", Type: SettingTypeText, Editable: false, HardLimit: true},
{Key: SettingGuestUploadsEnabled, EnvName: "WARPBOX_GUEST_UPLOADS_ENABLED", Label: "Guest uploads enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
@@ -20,6 +21,20 @@ var Definitions = []SettingDefinition{
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
{Key: SettingSecurityEnabled, EnvName: "WARPBOX_SECURITY_ENABLED", Label: "Security features enabled", Type: SettingTypeBool, Editable: true},
{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: SettingTrustedProxyCIDRs, EnvName: "WARPBOX_TRUSTED_PROXY_CIDRS", Label: "Trusted proxy CIDRs", Type: SettingTypeText, Editable: true},
{Key: SettingSecurityLoginWindowSecs, EnvName: "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", Label: "Login attempt window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityLoginMaxAttempts, EnvName: "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", Label: "Login max attempts per window", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingSecurityBanSeconds, EnvName: "WARPBOX_SECURITY_BAN_SECONDS", Label: "Security ban seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityScanWindowSecs, EnvName: "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", Label: "Malicious path window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityScanMaxAttempts, EnvName: "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", Label: "Malicious path max attempts", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingSecurityUploadWindowSecs, EnvName: "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", Label: "Upload limit window seconds", Type: SettingTypeInt64, Editable: true, Minimum: 10},
{Key: SettingSecurityUploadMaxRequests, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", Label: "Upload max requests per window", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingSecurityUploadMaxGB, EnvName: "WARPBOX_SECURITY_UPLOAD_MAX_GB", Label: "Upload max total GB per window", Type: SettingTypeSizeGB, Editable: true, Minimum: 0},
{Key: SettingExpiredCleanupIntervalSecs, EnvName: "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", Label: "Expired boxes cleanup interval seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
}
func (cfg *Config) SettingRows() []SettingRow {

View File

@@ -11,6 +11,7 @@ import (
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
Environment: AppEnvironmentDevelopment,
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
@@ -26,6 +27,17 @@ func Load() (*Config, error) {
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
ActivityRetentionSeconds: 7 * 24 * 60 * 60,
SecurityEnabled: true,
SecurityLoginWindowSeconds: 10 * 60,
SecurityLoginMaxAttempts: 8,
SecurityBanSeconds: 30 * 60,
SecurityScanWindowSeconds: 5 * 60,
SecurityScanMaxAttempts: 12,
SecurityUploadWindowSeconds: 60,
SecurityUploadMaxRequests: 20,
SecurityUploadMaxBytes: 10 * 1024 * 1024 * 1024,
ExpiredCleanupIntervalSeconds: 300,
sources: make(map[string]Source),
values: make(map[string]string),
defaults: make(map[string]string),
@@ -38,6 +50,14 @@ func Load() (*Config, error) {
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ENV")); raw != "" {
env := AppEnvironment(strings.ToLower(raw))
if env != AppEnvironmentDevelopment && env != AppEnvironmentProduction {
return nil, fmt.Errorf("WARPBOX_ENV must be development or production")
}
cfg.Environment = env
cfg.setValue(SettingEnvironment, string(env), SourceEnv)
}
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
return nil, err
}
@@ -47,6 +67,15 @@ func Load() (*Config, error) {
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingSecurityIPWhitelist, "WARPBOX_SECURITY_IP_WHITELIST", &cfg.SecurityIPWhitelist); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil {
return nil, err
}
if err := cfg.applyStringEnv(SettingTrustedProxyCIDRs, "WARPBOX_TRUSTED_PROXY_CIDRS", &cfg.TrustedProxyCIDRs); err != nil {
return nil, err
}
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
mode := AdminEnabledMode(strings.ToLower(raw))
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
@@ -73,6 +102,7 @@ func Load() (*Config, error) {
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
{SettingSecurityEnabled, "WARPBOX_SECURITY_ENABLED", &cfg.SecurityEnabled},
}
for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
@@ -90,6 +120,12 @@ func Load() (*Config, error) {
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
{SettingActivityRetentionSeconds, "WARPBOX_ACTIVITY_RETENTION_SECONDS", 60, &cfg.ActivityRetentionSeconds},
{SettingSecurityLoginWindowSecs, "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", 10, &cfg.SecurityLoginWindowSeconds},
{SettingSecurityBanSeconds, "WARPBOX_SECURITY_BAN_SECONDS", 10, &cfg.SecurityBanSeconds},
{SettingSecurityScanWindowSecs, "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", 10, &cfg.SecurityScanWindowSeconds},
{SettingSecurityUploadWindowSecs, "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", 10, &cfg.SecurityUploadWindowSeconds},
{SettingExpiredCleanupIntervalSecs, "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", 0, &cfg.ExpiredCleanupIntervalSeconds},
}
for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
@@ -107,6 +143,7 @@ func Load() (*Config, error) {
{SettingGlobalMaxBoxSizeBytes, "WARPBOX_GLOBAL_MAX_BOX_SIZE_GB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_MB", "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", &cfg.GlobalMaxBoxSizeBytes},
{SettingDefaultUserMaxFileBytes, "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", &cfg.DefaultUserMaxFileSizeBytes},
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
{SettingSecurityUploadMaxGB, "WARPBOX_SECURITY_UPLOAD_MAX_GB", "WARPBOX_SECURITY_UPLOAD_MAX_MB", "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", &cfg.SecurityUploadMaxBytes},
}
for _, item := range sizeEnvVars {
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
@@ -123,6 +160,9 @@ func Load() (*Config, error) {
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingSecurityLoginMaxAttempts, "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", 1, &cfg.SecurityLoginMaxAttempts},
{SettingSecurityScanMaxAttempts, "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", 1, &cfg.SecurityScanMaxAttempts},
{SettingSecurityUploadMaxRequests, "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", 1, &cfg.SecurityUploadMaxRequests},
}
for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
@@ -138,6 +178,15 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
}
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
if err := validateSecurityTextSetting(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist); err != nil {
return nil, err
}
if err := validateSecurityTextSetting(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist); err != nil {
return nil, err
}
if err := validateSecurityTextSetting(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs); err != nil {
return nil, err
}
cfg.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
@@ -154,6 +203,7 @@ func (cfg *Config) EnsureDirectories() error {
}
func (cfg *Config) captureDefaults() {
cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
cfg.captureDefaultValue(SettingEnvironment, string(cfg.Environment))
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
@@ -172,6 +222,20 @@ func (cfg *Config) captureDefaults() {
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
cfg.captureDefaultValue(SettingActivityRetentionSeconds, strconv.FormatInt(cfg.ActivityRetentionSeconds, 10))
cfg.captureDefaultValue(SettingSecurityEnabled, formatBool(cfg.SecurityEnabled))
cfg.captureDefaultValue(SettingSecurityIPWhitelist, cfg.SecurityIPWhitelist)
cfg.captureDefaultValue(SettingSecurityAdminIPWhitelist, cfg.SecurityAdminIPWhitelist)
cfg.captureDefaultValue(SettingTrustedProxyCIDRs, cfg.TrustedProxyCIDRs)
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))
cfg.captureDefaultValue(SettingExpiredCleanupIntervalSecs, strconv.FormatInt(cfg.ExpiredCleanupIntervalSeconds, 10))
}
func (cfg *Config) captureDefaultValue(key string, value string) {

View File

@@ -16,6 +16,13 @@ const (
AdminEnabledFalse AdminEnabledMode = "false"
)
type AppEnvironment string
const (
AppEnvironmentDevelopment AppEnvironment = "development"
AppEnvironmentProduction AppEnvironment = "production"
)
const (
SettingGuestUploadsEnabled = "guest_uploads_enabled"
SettingAPIEnabled = "api_enabled"
@@ -36,6 +43,21 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir"
SettingEnvironment = "environment"
SettingActivityRetentionSeconds = "activity_retention_seconds"
SettingSecurityEnabled = "security_enabled"
SettingSecurityIPWhitelist = "security_ip_whitelist"
SettingSecurityAdminIPWhitelist = "security_admin_ip_whitelist"
SettingTrustedProxyCIDRs = "trusted_proxy_cidrs"
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"
SettingExpiredCleanupIntervalSecs = "expired_cleanup_interval_seconds"
)
type SettingType string
@@ -72,6 +94,7 @@ type Config struct {
AdminPassword string
AdminUsername string
AdminEmail string
Environment AppEnvironment
AdminEnabled AdminEnabledMode
AdminCookieSecure bool
AllowAdminSettingsOverride bool
@@ -95,6 +118,20 @@ type Config struct {
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
ActivityRetentionSeconds int64
SecurityEnabled bool
SecurityIPWhitelist string
SecurityAdminIPWhitelist string
TrustedProxyCIDRs string
SecurityLoginWindowSeconds int64
SecurityLoginMaxAttempts int
SecurityBanSeconds int64
SecurityScanWindowSeconds int64
SecurityScanMaxAttempts int
SecurityUploadWindowSeconds int64
SecurityUploadMaxRequests int
SecurityUploadMaxBytes int64
ExpiredCleanupIntervalSeconds int64
sources map[string]Source
values map[string]string

View File

@@ -3,6 +3,9 @@ package config
import (
"fmt"
"strconv"
"strings"
"warpbox/lib/security"
)
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
@@ -26,6 +29,11 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("setting %q cannot be changed from the admin UI", key)
}
value = strings.TrimSpace(value)
if err := validateSecurityTextSetting(key, value); err != nil {
return err
}
switch def.Type {
case SettingTypeBool:
parsed, err := parseBool(value)
@@ -51,11 +59,28 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
return fmt.Errorf("%s: %w", key, err)
}
cfg.assignInt(key, int(parsed64), SourceDB)
case SettingTypeText:
cfg.assignText(key, value, SourceDB)
default:
return fmt.Errorf("setting %q is not runtime editable", key)
}
return nil
}
func validateSecurityTextSetting(key string, value string) error {
switch key {
case SettingSecurityIPWhitelist, SettingSecurityAdminIPWhitelist:
if _, err := security.ParseIPMatchers(value, true); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
case SettingTrustedProxyCIDRs:
if _, err := security.ParseCIDRList(value); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
}
return nil
}
func (cfg *Config) assignBool(key string, value bool, source Source) {
switch key {
case SettingGuestUploadsEnabled:
@@ -70,6 +95,8 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
case SettingSecurityEnabled:
cfg.SecurityEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
@@ -92,8 +119,22 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
case SettingActivityRetentionSeconds:
cfg.ActivityRetentionSeconds = value
case SettingSecurityLoginWindowSecs:
cfg.SecurityLoginWindowSeconds = value
case SettingSecurityBanSeconds:
cfg.SecurityBanSeconds = value
case SettingSecurityScanWindowSecs:
cfg.SecurityScanWindowSeconds = value
case SettingSecurityUploadWindowSecs:
cfg.SecurityUploadWindowSeconds = value
case SettingSecurityUploadMaxGB:
cfg.SecurityUploadMaxBytes = value
case SettingExpiredCleanupIntervalSecs:
cfg.ExpiredCleanupIntervalSeconds = value
}
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes {
if key == SettingGlobalMaxFileSizeBytes || key == SettingGlobalMaxBoxSizeBytes || key == SettingDefaultUserMaxFileBytes || key == SettingDefaultUserMaxBoxBytes || key == SettingSecurityUploadMaxGB {
cfg.setValue(key, formatGigabytesFromBytes(value), source)
return
}
@@ -108,10 +149,28 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
case SettingSecurityLoginMaxAttempts:
cfg.SecurityLoginMaxAttempts = value
case SettingSecurityScanMaxAttempts:
cfg.SecurityScanMaxAttempts = value
case SettingSecurityUploadMaxRequests:
cfg.SecurityUploadMaxRequests = value
}
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
case SettingTrustedProxyCIDRs:
cfg.TrustedProxyCIDRs = value
}
cfg.setValue(key, value, source)
}
func (cfg *Config) setValue(key string, value string, source Source) {
if key == "" {
return

View File

@@ -3,6 +3,7 @@ package routing
import "github.com/gin-gonic/gin"
type Handlers struct {
Health gin.HandlerFunc
Index gin.HandlerFunc
ShowBox gin.HandlerFunc
BoxLogin gin.HandlerFunc
@@ -25,15 +26,29 @@ type Handlers struct {
AdminBoxes gin.HandlerFunc
AdminBoxesAction gin.HandlerFunc
AdminUsers gin.HandlerFunc
AdminUsersList gin.HandlerFunc
AdminUsersSave gin.HandlerFunc
AdminUsersDelete gin.HandlerFunc
AdminUserKeyCreate gin.HandlerFunc
AdminUserKeyRevoke 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
UserLogin gin.HandlerFunc
UserLogout gin.HandlerFunc
UserMe gin.HandlerFunc
UserCreateAPIKey gin.HandlerFunc
}
func Register(router *gin.Engine, handlers Handlers) {
router.GET("/health", handlers.Health)
router.GET("/", handlers.Index)
router.GET("/box/:id", handlers.ShowBox)
@@ -42,6 +57,7 @@ func Register(router *gin.Engine, handlers Handlers) {
router.GET("/box/:id/download", handlers.DownloadBox)
router.GET("/box/:id/files/:filename", handlers.DownloadFile)
router.GET("/box/:id/thumbnails/:file_id", handlers.DownloadThumbnail)
router.HEAD("/box/:id/files/:filename", handlers.DownloadFile)
router.POST("/box", handlers.CreateBox)
router.POST("/box/:id/login", handlers.BoxLoginPost)
@@ -51,6 +67,10 @@ func Register(router *gin.Engine, handlers Handlers) {
// Legacy upload routes are kept for compatibility with older clients.
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
router.POST("/upload", handlers.LegacyUpload)
router.POST("/auth/login", handlers.UserLogin)
router.POST("/auth/logout", handlers.UserLogout)
router.GET("/auth/me", handlers.UserMe)
router.POST("/auth/api-keys/create", handlers.UserCreateAPIKey)
admin := router.Group("/admin")
admin.GET("/login", handlers.AdminLogin)
@@ -60,9 +80,18 @@ func Register(router *gin.Engine, handlers Handlers) {
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("/users/list", handlers.AdminUsersList)
protected.POST("/users/save", handlers.AdminUsersSave)
protected.POST("/users/delete", handlers.AdminUsersDelete)
protected.POST("/users/api-keys/create", handlers.AdminUserKeyCreate)
protected.POST("/users/api-keys/revoke", handlers.AdminUserKeyRevoke)
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)

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

@@ -0,0 +1,426 @@
package security
import (
"encoding/binary"
"fmt"
"net"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/dgraph-io/badger/v4"
)
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 []ipMatcher
adminWhitelist []ipMatcher
banDB *badger.DB
}
type ipMatcher struct {
exact net.IP
prefix *net.IPNet
}
type uploadEvent struct {
at time.Time
bytes int64
}
type BanEntry struct {
IP string `json:"ip"`
Until time.Time `json:"until"`
}
const banKeyPrefix = "ban:"
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: []ipMatcher{},
adminWhitelist: []ipMatcher{},
}
}
func (g *Guard) Close() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.banDB == nil {
return nil
}
err := g.banDB.Close()
g.banDB = nil
return err
}
func (g *Guard) EnableBanPersistence(path string) error {
if strings.TrimSpace(path) == "" {
return nil
}
g.mu.Lock()
defer g.mu.Unlock()
if g.banDB != nil {
return nil
}
opts := badger.DefaultOptions(path)
opts.Logger = nil
db, err := badger.Open(opts)
if err != nil {
// Corruption-safe fallback: quarantine badger files and start fresh.
_ = os.Rename(path, path+".corrupt."+time.Now().UTC().Format("20060102T150405"))
db, err = badger.Open(opts)
}
if err != nil {
return err
}
g.banDB = db
if err := g.loadBansLocked(); err != nil {
_ = g.banDB.Close()
g.banDB = nil
return err
}
return nil
}
func (g *Guard) Reload(cfg Config) error {
ipWhitelist, err := ParseIPMatchers(cfg.IPWhitelist, true)
if err != nil {
return fmt.Errorf("ip whitelist: %w", err)
}
adminWhitelist, err := ParseIPMatchers(cfg.AdminIPWhitelist, true)
if err != nil {
return fmt.Errorf("admin ip whitelist: %w", err)
}
g.mu.Lock()
defer g.mu.Unlock()
g.ipWhitelist = ipWhitelist
g.adminWhitelist = adminWhitelist
return nil
}
func (g *Guard) IsWhitelisted(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return matchIP(g.ipWhitelist, ip)
}
func (g *Guard) IsAdminWhitelisted(ip string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return matchIP(g.adminWhitelist, ip) || matchIP(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)
g.deleteBanLocked(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()
until := time.Now().UTC().Add(time.Duration(seconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
}
func (g *Guard) BanUntil(ip string, until time.Time) {
if ip == "" || until.IsZero() {
return
}
g.mu.Lock()
defer g.mu.Unlock()
until = until.UTC()
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
}
func (g *Guard) Unban(ip string) {
if ip == "" {
return
}
g.mu.Lock()
defer g.mu.Unlock()
delete(g.bannedUntil, ip)
g.deleteBanLocked(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)
g.deleteBanLocked(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 {
until := now.Add(time.Duration(banSeconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
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 {
until := now.Add(time.Duration(banSeconds) * time.Second)
g.bannedUntil[ip] = until
g.saveBanLocked(ip, until)
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 ParseIPMatchers(raw string, allowCIDR bool) ([]ipMatcher, error) {
entries := []ipMatcher{}
for _, chunk := range strings.Split(raw, ",") {
value := strings.TrimSpace(chunk)
if value == "" {
continue
}
if strings.Contains(value, "/") {
if !allowCIDR {
return nil, fmt.Errorf("%q must be a CIDR", value)
}
_, network, err := net.ParseCIDR(value)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q", value)
}
entries = append(entries, ipMatcher{prefix: network})
continue
}
parsed := net.ParseIP(value)
if parsed == nil {
return nil, fmt.Errorf("invalid IP %q", value)
}
entries = append(entries, ipMatcher{exact: parsed})
}
return entries, nil
}
func ParseCIDRList(raw string) ([]net.IPNet, error) {
entries := []net.IPNet{}
for _, chunk := range strings.Split(raw, ",") {
value := strings.TrimSpace(chunk)
if value == "" {
continue
}
_, network, err := net.ParseCIDR(value)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q", value)
}
entries = append(entries, *network)
}
return entries, nil
}
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 matchIP(rules []ipMatcher, value string) bool {
ip := net.ParseIP(strings.TrimSpace(value))
if ip == nil {
return false
}
for _, rule := range rules {
if rule.exact != nil && rule.exact.Equal(ip) {
return true
}
if rule.prefix != nil && rule.prefix.Contains(ip) {
return true
}
}
return false
}
func (g *Guard) saveBanLocked(ip string, until time.Time) {
if g.banDB == nil || ip == "" || until.IsZero() {
return
}
seconds := int64(time.Until(until).Seconds())
if seconds <= 0 {
_ = g.banDB.Update(func(txn *badger.Txn) error {
return txn.Delete([]byte(banKeyPrefix + ip))
})
return
}
value := make([]byte, 8)
binary.BigEndian.PutUint64(value, uint64(until.Unix()))
_ = g.banDB.Update(func(txn *badger.Txn) error {
entry := badger.NewEntry([]byte(banKeyPrefix+ip), value).WithTTL(time.Duration(seconds) * time.Second)
return txn.SetEntry(entry)
})
}
func (g *Guard) deleteBanLocked(ip string) {
if g.banDB == nil || ip == "" {
return
}
_ = g.banDB.Update(func(txn *badger.Txn) error {
return txn.Delete([]byte(banKeyPrefix + ip))
})
}
func (g *Guard) loadBansLocked() error {
if g.banDB == nil {
return nil
}
now := time.Now().UTC()
loaded := map[string]time.Time{}
expired := [][]byte{}
err := g.banDB.View(func(txn *badger.Txn) error {
it := txn.NewIterator(badger.DefaultIteratorOptions)
defer it.Close()
for it.Seek([]byte(banKeyPrefix)); it.ValidForPrefix([]byte(banKeyPrefix)); it.Next() {
item := it.Item()
key := string(item.Key())
ip := strings.TrimPrefix(key, banKeyPrefix)
err := item.Value(func(val []byte) error {
if len(val) != 8 {
expired = append(expired, append([]byte(nil), item.Key()...))
return nil
}
unix := int64(binary.BigEndian.Uint64(val))
until := time.Unix(unix, 0).UTC()
if now.After(until) {
expired = append(expired, append([]byte(nil), item.Key()...))
return nil
}
loaded[ip] = until
return nil
})
if err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
g.bannedUntil = loaded
if len(expired) == 0 {
return nil
}
return g.banDB.Update(func(txn *badger.Txn) error {
for _, key := range expired {
if err := txn.Delete(key); err != nil {
return err
}
}
return nil
})
}

View File

@@ -0,0 +1,52 @@
package security
import (
"path/filepath"
"testing"
"time"
)
func TestGuardWhitelistSupportsIPAndCIDR(t *testing.T) {
g := NewGuard()
if err := g.Reload(Config{IPWhitelist: "203.0.113.10,10.0.0.0/8", AdminIPWhitelist: "192.168.1.0/24"}); err != nil {
t.Fatalf("Reload returned error: %v", err)
}
if !g.IsWhitelisted("203.0.113.10") || !g.IsWhitelisted("10.2.3.4") {
t.Fatal("expected IP and CIDR entries to match")
}
if !g.IsAdminWhitelisted("192.168.1.5") {
t.Fatal("expected admin CIDR whitelist match")
}
}
func TestGuardBanPersistenceAcrossRestart(t *testing.T) {
dir := filepath.Join(t.TempDir(), "bans.badger")
g1 := NewGuard()
if err := g1.EnableBanPersistence(dir); err != nil {
t.Fatalf("EnableBanPersistence returned error: %v", err)
}
g1.Ban("198.51.100.4", 3600)
if err := g1.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
g2 := NewGuard()
if err := g2.EnableBanPersistence(dir); err != nil {
t.Fatalf("EnableBanPersistence returned error: %v", err)
}
defer g2.Close()
if !g2.IsBanned("198.51.100.4") {
t.Fatal("expected ban to persist across guard restart")
}
}
func TestGuardBanListPrunesExpired(t *testing.T) {
g := NewGuard()
g.BanUntil("198.51.100.7", time.Now().UTC().Add(-time.Minute))
if g.IsBanned("198.51.100.7") {
t.Fatal("expected expired ban to be treated as inactive")
}
if len(g.BanList()) != 0 {
t.Fatal("expected BanList to prune expired entries")
}
}

View File

@@ -2,16 +2,95 @@ package server
import (
"net/http"
"strconv"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts"
"warpbox/lib/config"
"warpbox/lib/helpers"
"warpbox/lib/security"
"warpbox/lib/userstore"
)
const adminSessionCookie = "warpbox_admin_session"
const adminSessionMarker = "1"
type adminDashboardView struct {
ActiveBoxes int `json:"active_boxes"`
BoxesCreatedToday int `json:"boxes_created_today"`
PasswordedBoxes int `json:"passworded_boxes"`
StorageUsedLabel string `json:"storage_used_label"`
StorageFreeLabel string `json:"storage_free_label"`
StorageCapLabel string `json:"storage_cap_label"`
StorageMeter string `json:"storage_meter"`
StorageBackend string `json:"storage_backend"`
OpenAlerts int `json:"open_alerts"`
HighAlerts int `json:"high_alerts"`
MediumAlerts int `json:"medium_alerts"`
LowAlerts int `json:"low_alerts"`
TotalUsers int `json:"total_users"`
ActiveUsers int `json:"active_users"`
DisabledUsers int `json:"disabled_users"`
APIKeyCount int `json:"api_key_count"`
GuestUploadsLabel string `json:"guest_uploads_label"`
APIUploadsLabel string `json:"api_uploads_label"`
ZipDownloadsLabel string `json:"zip_downloads_label"`
Alerts []adminDashboardAlert `json:"alerts"`
Events []adminDashboardActivity `json:"events"`
Boxes []adminDashboardBox `json:"boxes"`
}
type adminDashboardAlert struct {
ID string `json:"id"`
Title string `json:"title"`
Severity string `json:"severity"`
Status string `json:"status"`
Group string `json:"group"`
Code string `json:"code"`
Trace string `json:"trace"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
CreatedAtLabel string `json:"created_at_label"`
Meta map[string]string `json:"meta,omitempty"`
}
type adminDashboardActivity struct {
ID string `json:"id"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Message string `json:"message"`
IP string `json:"ip"`
Path string `json:"path"`
Method string `json:"method"`
CreatedAt string `json:"created_at"`
CreatedAtLabel string `json:"created_at_label"`
Meta map[string]string `json:"meta,omitempty"`
TagClass string `json:"tag_class"`
TagLabel string `json:"tag_label"`
}
type adminDashboardBox struct {
ID string `json:"id"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
StatusClass string `json:"status_class"`
FileCount int `json:"file_count"`
CompleteFiles int `json:"complete_files"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"`
ExpiresAtLabel string `json:"expires_at_label"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
OpenURL string `json:"open_url"`
ZipURL string `json:"zip_url"`
Flags []string `json:"flags"`
}
func (app *App) adminLoginEnabled() bool {
return app.config.AdminLoginEnabled(app.config.AdminPassword != "")
}
@@ -59,17 +138,39 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ip := app.clientIP(ctx)
guard := app.securityGuard
if app.securityFeaturesEnabled() && guard == nil {
guard = security.NewGuard()
app.securityGuard = guard
}
if app.securityFeaturesEnabled() && guard != nil && !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 app.securityFeaturesEnabled() && guard != nil && !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)
@@ -94,23 +195,307 @@ func (app *App) handleAdminDashboard(ctx *gin.Context) {
dashboardEnabled = cfgVal
}
dashboard := app.buildAdminDashboardView()
ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "dashboard",
"DashboardEnabled": string(dashboardEnabled),
"Dashboard": dashboard,
"AlertChipClass": adminAlertChipClass(dashboard.OpenAlerts, dashboard.HighAlerts, dashboard.MediumAlerts),
"AlertChipLabel": adminAlertChipLabel(dashboard.OpenAlerts),
})
}
func (app *App) buildAdminDashboardView() adminDashboardView {
boxes, _ := app.listAdminBoxes()
alertsList := []alerts.Alert{}
if app.alertStore != nil {
alertsList, _ = app.alertStore.List(500)
}
events := []activity.Event{}
if app.activityStore != nil {
events, _ = app.activityStore.List(80, app.config.ActivityRetentionSeconds)
}
users := []userstore.User{}
if app.userStore != nil {
users = app.userStore.List()
}
view := adminDashboardView{
StorageBackend: "local",
GuestUploadsLabel: adminBoolLabel(app.config.GuestUploadsEnabled && app.config.APIEnabled),
APIUploadsLabel: adminBoolLabel(app.config.APIEnabled),
ZipDownloadsLabel: adminBoolLabel(app.config.ZipDownloadsEnabled),
Alerts: make([]adminDashboardAlert, 0, 12),
Events: make([]adminDashboardActivity, 0, 15),
Boxes: make([]adminDashboardBox, 0, 12),
}
now := time.Now().UTC()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
usedBytes := int64(0)
for _, box := range boxes {
usedBytes += box.TotalSizeBytes
createdAt, _ := time.Parse(time.RFC3339, box.CreatedAtISO)
if !createdAt.IsZero() && createdAt.After(dayStart) {
view.BoxesCreatedToday++
}
if box.PasswordProtected {
view.PasswordedBoxes++
}
if box.Status != "expired" && box.Status != "consumed" {
view.ActiveBoxes++
}
if len(view.Boxes) < 12 {
view.Boxes = append(view.Boxes, adminDashboardBox{
ID: box.ID,
Status: box.Status,
StatusLabel: box.StatusLabel,
StatusClass: adminDashboardStatusClass(box.Status),
FileCount: box.FileCount,
CompleteFiles: box.CompleteFiles,
TotalSizeLabel: box.TotalSizeLabel,
CreatedAtLabel: box.CreatedAtLabel,
ExpiresAtLabel: box.ExpiresAtLabel,
PasswordProtected: box.PasswordProtected,
OneTimeDownload: box.OneTimeDownload,
OpenURL: box.OpenURL,
ZipURL: box.ZipURL,
Flags: box.Flags,
})
}
}
view.StorageUsedLabel = helpers.FormatBytes(usedBytes)
view.StorageCapLabel = "unknown"
view.StorageFreeLabel = "unknown"
view.StorageMeter = "0%"
if diskTotal, diskFree, ok := adminDiskCapacity(app.config.UploadsDir); ok && diskTotal > 0 {
diskUsed := diskTotal - diskFree
if diskUsed < 0 {
diskUsed = 0
}
view.StorageUsedLabel = helpers.FormatBytes(diskUsed)
view.StorageFreeLabel = helpers.FormatBytes(diskFree)
view.StorageCapLabel = helpers.FormatBytes(diskTotal)
percent := float64(diskUsed) / float64(diskTotal) * 100
if percent > 100 {
percent = 100
}
view.StorageMeter = strconv.FormatFloat(percent, 'f', 1, 64) + "%"
}
for _, alert := range alertsList {
if isUnacknowledgedAlert(alert) {
view.OpenAlerts++
switch alert.Severity {
case "high":
view.HighAlerts++
case "medium":
view.MediumAlerts++
default:
view.LowAlerts++
}
if len(view.Alerts) < 12 {
view.Alerts = append(view.Alerts, adminDashboardAlert{
ID: alert.ID,
Title: alert.Title,
Severity: adminFallback(alert.Severity, "low"),
Status: adminFallback(string(alert.Status), "open"),
Group: alert.Group,
Code: alert.Code,
Trace: alert.Trace,
Message: alert.Message,
CreatedAt: formatBrowserTime(alert.CreatedAt),
CreatedAtLabel: adminShortTimeLabel(alert.CreatedAt),
Meta: alert.Meta,
})
}
}
}
for _, event := range events {
if len(view.Events) >= 15 {
break
}
view.Events = append(view.Events, adminDashboardActivity{
ID: event.ID,
Kind: event.Kind,
Severity: adminFallback(event.Severity, "low"),
Message: event.Message,
IP: event.IP,
Path: event.Path,
Method: event.Method,
CreatedAt: formatBrowserTime(event.CreatedAt),
CreatedAtLabel: adminShortTimeLabel(event.CreatedAt),
Meta: event.Meta,
TagClass: adminSeverityTagClass(event.Severity),
TagLabel: adminActivityTagLabel(event.Kind),
})
}
for _, user := range users {
view.TotalUsers++
if user.Status == userstore.StatusDisabled {
view.DisabledUsers++
} else {
view.ActiveUsers++
}
for _, key := range user.APIKeys {
if key.RevokedAt == nil {
view.APIKeyCount++
}
}
}
return view
}
func adminBoolLabel(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
func adminFallback(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func adminShortTimeLabel(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.UTC().Format("15:04")
}
func adminDashboardStatusClass(status string) string {
switch status {
case "ready":
return "ok"
case "uploading", "legacy":
return "warn"
case "attention", "expired", "consumed":
return "danger"
default:
return "info"
}
}
func adminSeverityTagClass(severity string) string {
switch severity {
case "high":
return "danger"
case "medium":
return "warn"
case "low":
return "ok"
default:
return "info"
}
}
func adminActivityTagLabel(kind string) string {
parts := strings.Split(kind, ".")
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return "event"
}
return parts[0]
}
func adminDiskCapacity(path string) (int64, int64, bool) {
if strings.TrimSpace(path) == "" {
return 0, 0, false
}
var stats syscall.Statfs_t
if err := syscall.Statfs(path, &stats); err != nil {
return 0, 0, false
}
blockSize := int64(stats.Bsize)
if blockSize <= 0 {
return 0, 0, false
}
total := int64(stats.Blocks) * blockSize
free := int64(stats.Bavail) * blockSize
return total, free, true
}
func adminAlertChipClass(total int, high int, medium int) string {
score := high*5 + medium*2 + (total - high - medium)
switch {
case high > 0 || score >= 12:
return "is-danger"
case medium >= 2 || score >= 5:
return "is-warning"
case total > 0:
return "is-info"
default:
return "is-ok"
}
}
func adminAlertChipLabel(total int) string {
if total == 0 {
return "OK no alerts"
}
return "! " + strconv.Itoa(total) + " alerts"
}
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
mediumCount := 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" && isUnacknowledgedAlert(alert) {
highCount++
}
if alert.Severity == "medium" && isUnacknowledgedAlert(alert) {
mediumCount++
}
}
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "alerts",
"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),
"AlertChipClass": adminAlertChipClass(openCount, highCount, mediumCount),
"AlertChipLabel": adminAlertChipLabel(openCount),
})
}
func isUnacknowledgedAlert(alert alerts.Alert) bool {
return alert.Status == "" || alert.Status == alerts.StatusOpen
}

View File

@@ -0,0 +1,38 @@
package server
import (
"path/filepath"
"testing"
"warpbox/lib/alerts"
"warpbox/lib/config"
)
func TestAdminDashboardCountsOnlyUnacknowledgedAlerts(t *testing.T) {
store := alerts.NewStore(filepath.Join(t.TempDir(), "alerts.json"))
for _, alert := range []alerts.Alert{
{ID: "open-high", Title: "Open high", Severity: "high", Status: alerts.StatusOpen},
{ID: "acked-high", Title: "Acked high", Severity: "high", Status: alerts.StatusAcked},
{ID: "closed-medium", Title: "Closed medium", Severity: "medium", Status: alerts.StatusClosed},
} {
if err := store.Add(alert); err != nil {
t.Fatalf("Add returned error: %v", err)
}
}
app := &App{
config: &config.Config{},
alertStore: store,
}
view := app.buildAdminDashboardView()
if view.OpenAlerts != 1 {
t.Fatalf("expected only unacknowledged alerts in dashboard count, got %d", view.OpenAlerts)
}
if view.HighAlerts != 1 || view.MediumAlerts != 0 || view.LowAlerts != 0 {
t.Fatalf("expected only open alert severities, got high=%d medium=%d low=%d", view.HighAlerts, view.MediumAlerts, view.LowAlerts)
}
if len(view.Alerts) != 1 || view.Alerts[0].ID != "open-high" {
t.Fatalf("expected only open alert in dashboard inbox, got %#v", view.Alerts)
}
}

View File

@@ -37,6 +37,7 @@ type adminBoxView struct {
CompleteFiles int `json:"complete_files"`
PendingFiles int `json:"pending_files"`
FailedFiles int `json:"failed_files"`
TotalSizeBytes int64 `json:"total_size_bytes"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"`
CreatedAtISO string `json:"created_at_iso"`
@@ -84,22 +85,41 @@ func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
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":
case "delete", "expire", "bump", "cleanup_expired":
default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
return
}
if request.Action != "cleanup_expired" && len(request.BoxIDs) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
return
}
if request.Action == "bump" && request.DeltaSeconds <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
return
}
if request.Action == "cleanup_expired" {
result, err := app.runExpiredCleanup("admin")
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Expired cleanup job failed"})
return
}
boxes, listErr := app.listAdminBoxes()
if listErr != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Cleanup finished, but boxes could not be reloaded"})
return
}
ctx.JSON(http.StatusOK, gin.H{
"ok": len(result.Warnings) == 0,
"message": fmt.Sprintf("Expired cleanup done: deleted %d box(es), skipped %d", result.Deleted, result.Skipped),
"warnings": result.Warnings,
"boxes": boxes,
})
return
}
processed := 0
warnings := make([]string, 0)
@@ -184,6 +204,7 @@ func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
boxView := adminBoxView{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeBytes: summary.TotalSize,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
CreatedAtISO: formatBrowserTime(summary.CreatedAt),
@@ -299,6 +320,8 @@ func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) s
return fmt.Sprintf("Expired %d box(es)", processed)
case "bump":
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
case "cleanup_expired":
return fmt.Sprintf("Expired cleanup processed %d box(es)", processed)
default:
return "Action complete"
}

View File

@@ -0,0 +1,335 @@
package server
import (
"fmt"
"net"
"net/http"
"path/filepath"
"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"`
IPs []string `json:"ips"`
BanUntil string `json:"ban_until"`
}
func (app *App) reloadSecurityConfig() error {
if app == nil || app.config == nil {
return fmt.Errorf("app or config is nil")
}
if !app.securityFeaturesEnabled() {
if app.securityGuard != nil {
_ = app.securityGuard.Close()
}
app.securityGuard = nil
return nil
}
if app.securityGuard == nil {
app.securityGuard = security.NewGuard()
}
if err := app.securityGuard.EnableBanPersistence(filepath.Join(app.config.DBDir, "bans.badger")); err != nil {
return fmt.Errorf("enable ban persistence: %w", err)
}
if err := 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,
}); err != nil {
return fmt.Errorf("reload guard config: %w", err)
}
return nil
}
func (app *App) securityFeaturesEnabled() bool {
return app != nil && app.config != nil && app.config.SecurityEnabled
}
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 = app.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 ctx.Request != nil && ctx.Request.URL != nil && ctx.Request.URL.Path == "/health" {
ctx.Next()
return
}
if !app.securityFeaturesEnabled() {
ctx.Next()
return
}
if app.securityGuard == nil {
ctx.Next()
return
}
ip := app.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.securityFeaturesEnabled() {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
return
}
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 := app.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) {
if !app.securityFeaturesEnabled() {
ctx.String(http.StatusNotFound, "Security features are disabled")
return
}
events := []activity.Event{}
alertsList := []alerts.Alert{}
if app.activityStore != nil {
events, _ = app.activityStore.List(300, app.config.ActivityRetentionSeconds)
}
if app.alertStore != nil {
alertsList, _ = app.alertStore.List(120)
}
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) recordManualBanAction(ctx *gin.Context, kind string, message string, severity string, ip string, meta map[string]string, alertTitle string, alertSeverity string, code string, trace string, alertMessage string) {
metaCopy := map[string]string{"ip": ip}
for k, v := range meta {
metaCopy[k] = v
}
app.logActivity(kind, severity, message, ctx, metaCopy)
app.createAlert(alertTitle, alertSeverity, "security", code, trace, alertMessage, metaCopy)
}
func (app *App) handleAdminSecurityAction(ctx *gin.Context) {
if !app.securityFeaturesEnabled() {
ctx.JSON(http.StatusNotFound, gin.H{"error": "Security features are disabled"})
return
}
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.recordManualBanAction(ctx, "security.manual_ban", "Admin banned IP", "high", ip, nil, "IP manually banned by admin", "medium", "420", "security.manual.ban", "Admin manually applied temporary ban.")
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)
meta := map[string]string{"until": until.UTC().Format(time.RFC3339)}
app.recordManualBanAction(ctx, "security.manual_ban_until", "Admin set custom ban expiration", "high", ip, meta, "Custom IP ban applied by admin", "medium", "421", "security.manual.ban_until", "Admin set explicit ban expiration date.")
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.recordManualBanAction(ctx, "security.manual_unban", "Admin unbanned IP", "medium", ip, nil, "IP unbanned by admin", "low", "422", "security.manual.unban", "Admin manually removed temporary ban.")
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "IP unbanned", "bans": app.securityGuard.BanList()})
case "bulk_unban":
if len(request.IPs) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing IP list"})
return
}
count := 0
for _, candidate := range request.IPs {
candidate = strings.TrimSpace(candidate)
if net.ParseIP(candidate) == nil {
continue
}
app.securityGuard.Unban(candidate)
count++
}
app.logActivity("security.manual_bulk_unban", "high", "Admin unbanned multiple IPs", ctx, map[string]string{"count": intToString(count)})
app.createAlert("Bulk IP unban by admin", "medium", "security", "423", "security.manual.bulk_unban", "Admin manually removed multiple temporary bans.", map[string]string{"count": intToString(count)})
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "Bulk unban complete", "bans": app.securityGuard.BanList()})
case "unban_all":
current := app.securityGuard.BanList()
for _, ban := range current {
app.securityGuard.Unban(ban.IP)
}
count := len(current)
app.logActivity("security.manual_unban_all", "high", "Admin cleared all active bans", ctx, map[string]string{"count": intToString(count)})
app.createAlert("All active bans cleared by admin", "medium", "security", "424", "security.manual.unban_all", "Admin manually removed all temporary bans.", map[string]string{"count": intToString(count)})
ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "All bans cleared", "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

@@ -0,0 +1,146 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts"
"warpbox/lib/config"
"warpbox/lib/security"
)
func TestSecurityMiddlewareAllowsHealthCheckFromBannedIP(t *testing.T) {
app := &App{
config: &config.Config{SecurityEnabled: true},
securityGuard: security.NewGuard(),
}
app.securityGuard.Ban("172.30.0.1", 300)
router := gin.New()
router.Use(app.securityMiddleware())
router.GET("/health", app.handleHealth)
request := httptest.NewRequest(http.MethodGet, "/health", nil)
request.RemoteAddr = "172.30.0.1:12345"
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected health check to pass, got %d", response.Code)
}
}
func TestAdminSecurityActionsWriteAuditTrail(t *testing.T) {
app, router := setupAdminSecurityTest(t)
for _, body := range []string{
`{"action":"ban","ip":"203.0.113.7"}`,
`{"action":"unban","ip":"203.0.113.7"}`,
} {
request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(body))
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 body=%s", response.Code, response.Body.String())
}
}
events, err := app.activityStore.List(100, app.config.ActivityRetentionSeconds)
if err != nil {
t.Fatalf("activity list error: %v", err)
}
if len(events) < 2 {
t.Fatalf("expected activity events, got %d", len(events))
}
alertsList, err := app.alertStore.List(100)
if err != nil {
t.Fatalf("alerts list error: %v", err)
}
if len(alertsList) < 2 {
t.Fatalf("expected alerts for manual actions, got %d", len(alertsList))
}
}
func TestAdminSecurityBulkUnbanAndUnbanAll(t *testing.T) {
app, router := setupAdminSecurityTest(t)
app.securityGuard.Ban("203.0.113.8", 300)
app.securityGuard.Ban("203.0.113.9", 300)
request := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"bulk_unban","ips":["203.0.113.8"]}`))
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("bulk_unban expected 200, got %d", response.Code)
}
if app.securityGuard.IsBanned("203.0.113.8") {
t.Fatal("expected selected IP to be unbanned")
}
if !app.securityGuard.IsBanned("203.0.113.9") {
t.Fatal("expected non-selected IP to remain banned")
}
requestAll := httptest.NewRequest(http.MethodPost, "/admin/security/actions", strings.NewReader(`{"action":"unban_all"}`))
requestAll.Header.Set("Content-Type", "application/json")
requestAll.AddCookie(authCookie(app))
responseAll := httptest.NewRecorder()
router.ServeHTTP(responseAll, requestAll)
if responseAll.Code != http.StatusOK {
t.Fatalf("unban_all expected 200, got %d", responseAll.Code)
}
if len(app.securityGuard.BanList()) != 0 {
t.Fatal("expected all bans to be removed")
}
}
func setupAdminSecurityTest(t *testing.T) (*App, *gin.Engine) {
t.Helper()
gin.SetMode(gin.TestMode)
cwd, _ := os.Getwd()
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
if err := os.Chdir(root); err != nil {
t.Fatalf("chdir: %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")
cfg, err := config.Load()
if err != nil {
t.Fatalf("config load: %v", err)
}
if err := cfg.EnsureDirectories(); err != nil {
t.Fatalf("ensure dirs: %v", err)
}
app := &App{
config: cfg,
activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity.json")),
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
securityGuard: security.NewGuard(),
}
if err := app.reloadSecurityConfig(); err != nil {
t.Fatalf("reload security config: %v", err)
}
t.Cleanup(func() { _ = app.securityGuard.Close() })
router := gin.New()
admin := router.Group("/admin")
admin.GET("/login", app.handleAdminLogin)
protected := router.Group("/admin", app.adminAuthMiddleware)
protected.POST("/security/actions", app.handleAdminSecurityAction)
return app, router
}

View File

@@ -269,6 +269,9 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
app.config = nextCfg
applyBoxstoreRuntimeConfig(app.config)
if err := app.reloadSecurityConfig(); err != nil {
return nil, nil, err
}
rows, _ := app.buildAdminSettingsRows()
return rows, warnings, nil
}
@@ -399,6 +402,8 @@ func settingsCategoryMeta() []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: "▥"},
@@ -428,10 +433,16 @@ 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.SettingSecurityEnabled, 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:
@@ -440,6 +451,8 @@ func settingsCategoryForKey(key string) string {
return "storage"
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
return "workers"
case config.SettingExpiredCleanupIntervalSecs:
return "workers"
default:
return "accounts"
}
@@ -466,6 +479,19 @@ func settingsDescription(key string) string {
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.SettingSecurityEnabled: "Master switch for security middleware, automated bans, suspicious path detection, and upload throttling.",
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.",
config.SettingExpiredCleanupIntervalSecs: "Background interval for deleting expired boxes. Set 0 to disable periodic cleanup.",
}
return descriptions[key]
}

View File

@@ -236,6 +236,7 @@ func clearAdminSettingsEnv(t *testing.T) {
"WARPBOX_ADMIN_PASSWORD",
"WARPBOX_ADMIN_USERNAME",
"WARPBOX_ADMIN_EMAIL",
"WARPBOX_ENV",
"WARPBOX_ADMIN_ENABLED",
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
"WARPBOX_ADMIN_COOKIE_SECURE",
@@ -265,7 +266,36 @@ func clearAdminSettingsEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"WARPBOX_SECURITY_ENABLED",
"WARPBOX_SECURITY_IP_WHITELIST",
"WARPBOX_SECURITY_ADMIN_IP_WHITELIST",
"WARPBOX_TRUSTED_PROXY_CIDRS",
"WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS",
"WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS",
"WARPBOX_SECURITY_BAN_SECONDS",
"WARPBOX_SECURITY_SCAN_WINDOW_SECONDS",
"WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS",
"WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS",
"WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS",
"WARPBOX_SECURITY_UPLOAD_MAX_GB",
"WARPBOX_SECURITY_UPLOAD_MAX_MB",
"WARPBOX_SECURITY_UPLOAD_MAX_BYTES",
"WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS",
} {
t.Setenv(name, "")
}
}
func TestAdminSettingsSaveRejectsInvalidTrustedProxyCIDR(t *testing.T) {
app, router := setupAdminSettingsTest(t)
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"trusted_proxy_cidrs":"not-a-cidr"}}`))
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)
}
}

View File

@@ -2,10 +2,82 @@ package server
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/userstore"
)
const bytesPerMegabyte = 1024 * 1024
type adminUserView struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Status string `json:"status"`
Permissions userstore.Permissions `json:"permissions"`
Limits userstore.Limits `json:"limits"`
APIKeys []adminAPIKeyView `json:"api_keys"`
APIKeyCount int `json:"api_key_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastSeenAt string `json:"last_seen_at"`
}
type adminAPIKeyView struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at"`
RevokedAt string `json:"revoked_at"`
}
func formatMaybeTime(value *time.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func toAdminAPIKeyView(key userstore.APIKey) adminAPIKeyView {
return adminAPIKeyView{
ID: key.ID,
Name: key.Name,
Prefix: key.Prefix,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
LastUsedAt: formatMaybeTime(key.LastUsedAt),
RevokedAt: formatMaybeTime(key.RevokedAt),
}
}
func toAdminAPIKeyViews(keys []userstore.APIKey) []adminAPIKeyView {
views := make([]adminAPIKeyView, 0, len(keys))
for _, key := range keys {
views = append(views, toAdminAPIKeyView(key))
}
return views
}
func toAdminUserView(user userstore.User) adminUserView {
return adminUserView{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Status: user.Status,
Permissions: user.Permissions,
Limits: user.Limits,
APIKeys: toAdminAPIKeyViews(user.APIKeys),
APIKeyCount: len(user.APIKeys),
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: user.UpdatedAt.UTC().Format(time.RFC3339),
LastSeenAt: formatMaybeTime(user.LastSeenAt),
}
}
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
@@ -18,3 +90,154 @@ func (app *App) handleAdminUsers(ctx *gin.Context) {
"ActivePage": "users",
})
}
func (app *App) handleAdminUsersList(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
users := app.userStore.List()
items := make([]adminUserView, 0, len(users))
for _, user := range users {
items = append(items, toAdminUserView(user))
}
ctx.JSON(http.StatusOK, gin.H{"users": items})
}
func parseInt64OrZero(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func parseMegabytesToBytesOrZero(value string) int64 {
megabytes := parseInt64OrZero(value)
if megabytes <= 0 {
return 0
}
return megabytes * bytesPerMegabyte
}
func (app *App) handleAdminUsersSave(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Status string `json:"status"`
MaxFileMB string `json:"max_file_size_mb"`
MaxBoxMB string `json:"max_box_size_mb"`
MaxFileSize string `json:"max_file_size_bytes"`
MaxBoxSize string `json:"max_box_size_bytes"`
Permissions struct {
CanUseWeb bool `json:"can_use_web"`
CanUseAPI bool `json:"can_use_api"`
CanCreateBox bool `json:"can_create_box"`
CanUploadFile bool `json:"can_upload_file"`
} `json:"permissions"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user payload"})
return
}
permissions := userstore.Permissions{
CanUseWeb: payload.Permissions.CanUseWeb,
CanUseAPI: payload.Permissions.CanUseAPI,
CanCreateBox: payload.Permissions.CanCreateBox,
CanUploadFile: payload.Permissions.CanUploadFile,
}
limits := userstore.Limits{
MaxFileSizeBytes: parseMegabytesToBytesOrZero(payload.MaxFileMB),
MaxBoxSizeBytes: parseMegabytesToBytesOrZero(payload.MaxBoxMB),
}
if limits.MaxFileSizeBytes == 0 && strings.TrimSpace(payload.MaxFileSize) != "" {
limits.MaxFileSizeBytes = parseInt64OrZero(payload.MaxFileSize)
}
if limits.MaxBoxSizeBytes == 0 && strings.TrimSpace(payload.MaxBoxSize) != "" {
limits.MaxBoxSizeBytes = parseInt64OrZero(payload.MaxBoxSize)
}
var (
user userstore.User
err error
)
if strings.TrimSpace(payload.ID) == "" {
user, err = app.userStore.Create(payload.Username, payload.Email, permissions, limits, payload.Status)
} else {
user, err = app.userStore.Update(payload.ID, payload.Username, payload.Email, permissions, limits, payload.Status)
}
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true, "user": toAdminUserView(user)})
}
func (app *App) handleAdminUsersDelete(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
ID string `json:"id"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.ID) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
return
}
if err := app.userStore.Delete(payload.ID); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
func (app *App) handleAdminUserAPIKeyCreate(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
UserID string `json:"user_id"`
Name string `json:"name"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
return
}
key, raw, err := app.userStore.CreateAPIKey(payload.UserID, payload.Name)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true, "api_key": raw, "key": toAdminAPIKeyView(key)})
}
func (app *App) handleAdminUserAPIKeyRevoke(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
UserID string `json:"user_id"`
KeyID string `json:"key_id"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" || strings.TrimSpace(payload.KeyID) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id and key id are required"})
return
}
if err := app.userStore.RevokeAPIKey(payload.UserID, payload.KeyID); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}

62
lib/server/cleanup.go Normal file
View File

@@ -0,0 +1,62 @@
package server
import (
"log"
"strings"
"time"
"warpbox/lib/boxstore"
)
func (app *App) runExpiredCleanup(trigger string) (boxstore.CleanupExpiredResult, error) {
result, err := boxstore.CleanupExpiredBoxes()
if err != nil {
log.Printf("warpbox cleanup[%s] failed: %v", trigger, err)
app.logActivity("boxes.cleanup.failed", "high", "Expired boxes cleanup failed", nil, map[string]string{
"trigger": trigger,
"error": err.Error(),
})
return result, err
}
meta := map[string]string{
"trigger": trigger,
"scanned": intToString(result.Scanned),
"deleted": intToString(result.Deleted),
"skipped": intToString(result.Skipped),
}
if len(result.DeletedIDs) > 0 {
limit := len(result.DeletedIDs)
if limit > 20 {
limit = 20
}
meta["deleted_ids"] = strings.Join(result.DeletedIDs[:limit], ",")
}
if len(result.Warnings) > 0 {
limit := len(result.Warnings)
if limit > 3 {
limit = 3
}
meta["warnings"] = strings.Join(result.Warnings[:limit], " | ")
}
app.logActivity("boxes.cleanup", "medium", "Expired boxes cleanup run completed", nil, meta)
log.Printf("warpbox cleanup[%s] scanned=%d deleted=%d skipped=%d", trigger, result.Scanned, result.Deleted, result.Skipped)
return result, nil
}
func (app *App) startExpiredCleanupWorker() {
if app == nil || app.config == nil {
return
}
go func() {
for {
interval := app.config.ExpiredCleanupIntervalSeconds
if interval <= 0 {
time.Sleep(30 * time.Second)
continue
}
time.Sleep(time.Duration(interval) * time.Second)
_, _ = app.runExpiredCleanup("worker")
}
}()
}

View File

@@ -4,8 +4,10 @@ import (
"archive/zip"
"fmt"
"io"
"mime"
"net/http"
"os"
"strings"
"sync"
"github.com/gin-gonic/gin"
@@ -233,7 +235,8 @@ func (app *App) handleDownloadFile(ctx *gin.Context) {
return
}
if _, err := os.Stat(path); err != nil {
info, err := os.Stat(path)
if err != nil {
ctx.String(http.StatusNotFound, "File not found")
return
}
@@ -242,12 +245,49 @@ func (app *App) handleDownloadFile(ctx *gin.Context) {
return
}
ctx.FileAttachment(path, filename)
if !app.serveDownloadFile(ctx, path, filename, info) {
return
}
if hasManifest && app.config.RenewOnDownloadEnabled {
boxstore.RenewManifest(boxID, manifest.RetentionSecs)
}
}
func (app *App) serveDownloadFile(ctx *gin.Context, path string, filename string, info os.FileInfo) bool {
file, err := os.Open(path)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not read file")
return false
}
defer file.Close()
mimeType := helpers.MimeTypeForFile(path, filename)
ctx.Header("Content-Type", mimeType)
ctx.Header("Content-Disposition", contentDispositionForDownload(filename, mimeType))
ctx.Header("X-Content-Type-Options", "nosniff")
http.ServeContent(ctx.Writer, ctx.Request, filename, info.ModTime(), file)
return true
}
func contentDispositionForDownload(filename string, mimeType string) string {
disposition := "attachment"
if isEmbeddableMimeType(mimeType) {
disposition = "inline"
}
return mime.FormatMediaType(disposition, map[string]string{"filename": filename})
}
func isEmbeddableMimeType(mimeType string) bool {
baseType := strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0]))
if strings.HasPrefix(baseType, "video/") || strings.HasPrefix(baseType, "audio/") {
return true
}
if strings.HasPrefix(baseType, "image/") {
return baseType != "image/svg+xml"
}
return baseType == "application/pdf" || baseType == "text/plain"
}
func (app *App) handleDownloadThumbnail(ctx *gin.Context) {
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")

View File

@@ -0,0 +1,120 @@
package server
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/models"
)
const downloadTestBoxID = "abcdefabcdefabcdefabcdefabcdefab"
func TestDownloadFileServesEmbeddableMediaInlineWithRangeSupport(t *testing.T) {
app := setupDownloadFileTest(t, "clip.mp4", []byte("0123456789"))
response := performDownloadFile(app, http.MethodGet, "/box/"+downloadTestBoxID+"/files/clip.mp4", map[string]string{
"Range": "bytes=0-3",
})
if response.Code != http.StatusPartialContent {
t.Fatalf("expected ranged download to return 206, got %d", response.Code)
}
if got := response.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline;") || !strings.Contains(got, "filename=clip.mp4") {
t.Fatalf("expected inline content disposition for embeddable media, got %q", got)
}
if got := response.Header().Get("Content-Type"); !strings.HasPrefix(got, "video/mp4") {
t.Fatalf("expected video content type, got %q", got)
}
if got := response.Header().Get("Content-Range"); got != "bytes 0-3/10" {
t.Fatalf("expected byte range header, got %q", got)
}
if got := response.Body.String(); got != "0123" {
t.Fatalf("expected ranged body, got %q", got)
}
}
func TestDownloadFileServesUnsafeInlineTypesAsAttachments(t *testing.T) {
app := setupDownloadFileTest(t, "page.html", []byte("<!doctype html><script>alert(1)</script>"))
response := performDownloadFile(app, http.MethodGet, "/box/"+downloadTestBoxID+"/files/page.html", nil)
if response.Code != http.StatusOK {
t.Fatalf("expected download to return 200, got %d", response.Code)
}
if got := response.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment;") || !strings.Contains(got, "filename=page.html") {
t.Fatalf("expected attachment content disposition for html, got %q", got)
}
}
func TestDownloadFileSupportsHeadRequests(t *testing.T) {
app := setupDownloadFileTest(t, "clip.mp4", []byte("0123456789"))
response := performDownloadFile(app, http.MethodHead, "/box/"+downloadTestBoxID+"/files/clip.mp4", nil)
if response.Code != http.StatusOK {
t.Fatalf("expected HEAD download to return 200, got %d", response.Code)
}
if got := response.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline;") {
t.Fatalf("expected inline content disposition for HEAD request, got %q", got)
}
if response.Body.Len() != 0 {
t.Fatalf("expected HEAD response body to be empty, got %d bytes", response.Body.Len())
}
}
func setupDownloadFileTest(t *testing.T, filename string, body []byte) *App {
t.Helper()
gin.SetMode(gin.TestMode)
restoreUploadRoot := boxstore.UploadRoot()
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
boxstore.SetUploadRoot(t.TempDir())
if err := os.MkdirAll(boxstore.BoxPath(downloadTestBoxID), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
path, ok := boxstore.SafeBoxFilePath(downloadTestBoxID, filename)
if !ok {
t.Fatal("SafeBoxFilePath rejected test file")
}
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
manifest := models.BoxManifest{
Files: []models.BoxFile{{
ID: "0123456789abcdef",
Name: filename,
Size: int64(len(body)),
MimeType: "",
Status: models.FileStatusReady,
}},
CreatedAt: time.Now().UTC(),
}
if err := boxstore.WriteManifest(downloadTestBoxID, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
return &App{config: &config.Config{}}
}
func performDownloadFile(app *App, method string, path string, headers map[string]string) *httptest.ResponseRecorder {
router := gin.New()
router.GET("/box/:id/files/:filename", app.handleDownloadFile)
router.HEAD("/box/:id/files/:filename", app.handleDownloadFile)
request := httptest.NewRequest(method, path, nil)
for key, value := range headers {
request.Header.Set(key, value)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

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

@@ -0,0 +1,107 @@
package server
import (
"net"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/security"
)
func (app *App) clientIP(ctx *gin.Context) string {
if ctx == nil || ctx.Request == nil {
return ""
}
remoteIP := remoteAddrIP(ctx.Request)
trusted, err := security.ParseCIDRList(app.config.TrustedProxyCIDRs)
if err != nil {
return remoteIP
}
if !remoteIsTrusted(remoteIP, trusted) {
return 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 remoteIsTrusted(remoteIP string, trusted []net.IPNet) bool {
ip := net.ParseIP(strings.TrimSpace(remoteIP))
if ip == nil {
return false
}
for _, prefix := range trusted {
if prefix.Contains(ip) {
return true
}
}
return false
}
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()
}

55
lib/server/ip_test.go Normal file
View File

@@ -0,0 +1,55 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
)
func TestClientIPDirectClient(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "198.51.100.10:1234"
ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.4")
if got := app.clientIP(ctx); got != "198.51.100.10" {
t.Fatalf("expected direct remote IP, got %q", got)
}
}
func TestClientIPTrustedProxyChain(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "10.1.2.3:8080"
ctx.Request.Header.Set("X-Forwarded-For", "203.0.113.44, 10.0.0.5")
if got := app.clientIP(ctx); got != "203.0.113.44" {
t.Fatalf("expected forwarded public client IP, got %q", got)
}
}
func TestClientIPTrustedDockerBridgeProxy(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "172.30.0.1/32"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "172.30.0.1:8080"
ctx.Request.Header.Set("X-Forwarded-For", "198.51.100.55")
if got := app.clientIP(ctx); got != "198.51.100.55" {
t.Fatalf("expected forwarded client IP from trusted docker bridge, got %q", got)
}
}
func TestClientIPSpoofedHeaderFromUntrustedRemote(t *testing.T) {
app := &App{config: &config.Config{TrustedProxyCIDRs: "10.0.0.0/8"}}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest(http.MethodGet, "/", nil)
ctx.Request.RemoteAddr = "203.0.113.200:8080"
ctx.Request.Header.Set("X-Forwarded-For", "198.51.100.55")
if got := app.clientIP(ctx); got != "203.0.113.200" {
t.Fatalf("expected untrusted remote IP, got %q", got)
}
}

View File

@@ -24,6 +24,7 @@ func (app *App) handleIndex(ctx *gin.Context) {
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
"AppVersion": app.appVersion,
})
}

View File

@@ -3,20 +3,31 @@ package server
import (
"encoding/json"
"html/template"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/routing"
"warpbox/lib/security"
"warpbox/lib/userstore"
)
type App struct {
config *config.Config
settingsOverridesPath string
activityStore *activity.Store
alertStore *alerts.Store
securityGuard *security.Guard
appVersion string
userStore *userstore.Store
}
func Run(addr string) error {
@@ -24,6 +35,12 @@ func Run(addr string) error {
if err != nil {
return err
}
switch cfg.Environment {
case config.AppEnvironmentProduction:
gin.SetMode(gin.ReleaseMode)
default:
gin.SetMode(gin.DebugMode)
}
if err := cfg.EnsureDirectories(); err != nil {
return err
}
@@ -38,9 +55,27 @@ func Run(addr string) error {
applyBoxstoreRuntimeConfig(cfg)
app := &App{config: cfg, settingsOverridesPath: overridesPath}
app := &App{
config: cfg,
settingsOverridesPath: overridesPath,
activityStore: activity.NewStore(filepath.Join(cfg.DBDir, "activity_log.json")),
alertStore: alerts.NewStore(filepath.Join(cfg.DBDir, "alerts.json")),
securityGuard: security.NewGuard(),
appVersion: currentAppVersion(),
}
userStore, err := userstore.NewStore(filepath.Join(cfg.DBDir, "users.json"))
if err != nil {
return err
}
app.userStore = userStore
if err := app.reloadSecurityConfig(); err != nil {
return err
}
router := gin.Default()
router.Use(app.versionHeaderMiddleware())
router.Use(app.securityMiddleware())
router.NoRoute(app.handleNoRoute)
htmlTemplates, err := loadHTMLTemplates()
if err != nil {
return err
@@ -48,6 +83,7 @@ func Run(addr string) error {
router.SetHTMLTemplate(htmlTemplates)
routing.Register(router, routing.Handlers{
Health: app.handleHealth,
Index: app.handleIndex,
ShowBox: app.handleShowBox,
BoxLogin: handleBoxLogin,
@@ -70,18 +106,32 @@ func Run(addr string) error {
AdminBoxes: app.handleAdminBoxes,
AdminBoxesAction: app.handleAdminBoxesAction,
AdminUsers: app.handleAdminUsers,
AdminUsersList: app.handleAdminUsersList,
AdminUsersSave: app.handleAdminUsersSave,
AdminUsersDelete: app.handleAdminUsersDelete,
AdminUserKeyCreate: app.handleAdminUserAPIKeyCreate,
AdminUserKeyRevoke: app.handleAdminUserAPIKeyRevoke,
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,
UserLogin: app.handleUserLogin,
UserLogout: app.handleUserLogout,
UserMe: app.handleUserMe,
UserCreateAPIKey: app.handleSelfCreateAPIKey,
})
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
compressed.Static("/static", "./static")
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
app.startExpiredCleanupWorker()
return router.Run(addr)
}
@@ -114,3 +164,24 @@ func applyBoxstoreRuntimeConfig(cfg *config.Config) {
boxstore.SetUploadRoot(cfg.UploadsDir)
boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds)
}
func (app *App) handleHealth(c *gin.Context) {
c.JSON(200, gin.H{
"status": "healthy",
})
}
func (app *App) versionHeaderMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.Header("X-App-Version", app.appVersion)
ctx.Next()
}
}
func currentAppVersion() string {
version := strings.TrimSpace(os.Getenv("APP_VERSION"))
if version == "" {
return "dev"
}
return version
}

View File

@@ -17,7 +17,11 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
boxID, err := boxstore.NewBoxID()
if err != nil {
@@ -35,10 +39,17 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
return
}
if err := app.validateCreateBoxRequest(&request); err != nil {
if err := app.validateCreateBoxRequestForActor(&request, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize := int64(0)
for _, file := range request.Files {
totalSize += file.Size
}
if !app.enforceUploadRateLimit(ctx, totalSize) {
return
}
files, err := boxstore.CreateManifest(boxID, request)
if err != nil {
@@ -53,7 +64,11 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
boxID := ctx.Param("id")
fileID := ctx.Param("file_id")
@@ -68,11 +83,15 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil {
if err := app.validateManifestFileUploadForActor(boxID, fileID, file.Size, actor); err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !app.enforceUploadRateLimit(ctx, file.Size) {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
return
}
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
if err != nil {
@@ -124,7 +143,11 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) {
@@ -137,10 +160,13 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return
}
if err := app.validateIncomingFile(boxID, file.Size); err != nil {
if err := app.validateIncomingFileForActor(boxID, file.Size, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !app.enforceUploadRateLimit(ctx, file.Size) {
return
}
savedFile, err := boxstore.SaveUpload(boxID, file)
if err != nil {
@@ -155,7 +181,11 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return
}
app.limitRequestBody(ctx)
actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
form, err := ctx.MultipartForm()
if err != nil {
@@ -170,16 +200,19 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
}
totalSize := int64(0)
for _, file := range files {
if err := app.validateFileSize(file.Size); err != nil {
if err := app.validateFileSizeForActor(file.Size, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
totalSize += file.Size
}
if err := app.validateBoxSize(totalSize); err != nil {
if err := app.validateBoxSizeForActor(totalSize, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !app.enforceUploadRateLimit(ctx, totalSize) {
return
}
boxID, err := boxstore.NewBoxID()
if err != nil {
@@ -209,7 +242,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
for _, file := range files {
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
}
if err := app.validateCreateBoxRequest(&request); err != nil {
if err := app.validateCreateBoxRequestForActor(&request, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

188
lib/server/user_auth.go Normal file
View File

@@ -0,0 +1,188 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/userstore"
)
const userSessionCookie = "warpbox_user_session"
type requestActor struct {
User userstore.User
FromAPIKey bool
KeyID string
}
func requestBearerToken(ctx *gin.Context) string {
auth := strings.TrimSpace(ctx.GetHeader("Authorization"))
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
return ""
}
return strings.TrimSpace(auth[7:])
}
func (app *App) sessionSecret() string {
return app.config.AdminUsername + "|" + app.config.AdminPassword + "|warpbox"
}
func (app *App) signSessionToken(userID string, expiresAt time.Time) string {
payload := userID + "|" + expiresAt.UTC().Format(time.RFC3339)
mac := hmac.New(sha256.New, []byte(app.sessionSecret()))
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
}
func (app *App) parseSessionToken(token string) (string, bool) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
mac := hmac.New(sha256.New, []byte(app.sessionSecret()))
mac.Write([]byte(payload))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSig), []byte(parts[1])) {
return "", false
}
items := strings.Split(payload, "|")
if len(items) != 2 {
return "", false
}
expiresAt, err := time.Parse(time.RFC3339, items[1])
if err != nil || time.Now().UTC().After(expiresAt) {
return "", false
}
return items[0], true
}
func (app *App) resolveActor(ctx *gin.Context) (*requestActor, bool) {
if app.userStore == nil {
return nil, false
}
if rawKey := requestBearerToken(ctx); rawKey != "" {
user, key, ok := app.userStore.FindByAPIKey(rawKey)
if ok {
app.userStore.TouchAPIKey(user.ID, key.ID)
return &requestActor{User: user, FromAPIKey: true, KeyID: key.ID}, true
}
return nil, false
}
if token, err := ctx.Cookie(userSessionCookie); err == nil {
if userID, ok := app.parseSessionToken(token); ok {
if user, found := app.userStore.FindByID(userID); found {
app.userStore.TouchUser(user.ID)
return &requestActor{User: user}, true
}
}
}
return nil, false
}
func (app *App) denyActor(ctx *gin.Context, status int, message string) bool {
ctx.JSON(status, gin.H{"error": message})
return false
}
func (app *App) authorizeUpload(ctx *gin.Context) (*requestActor, bool) {
actor, ok := app.resolveActor(ctx)
if !ok {
if requestBearerToken(ctx) != "" {
return nil, app.denyActor(ctx, http.StatusUnauthorized, "Invalid API key")
}
return nil, true
}
if actor.User.Status != userstore.StatusActive {
return nil, app.denyActor(ctx, http.StatusForbidden, "User account is disabled")
}
if !actor.User.Permissions.CanUseAPI {
return nil, app.denyActor(ctx, http.StatusForbidden, "API access is not allowed for this user")
}
if !actor.User.Permissions.CanCreateBox {
return nil, app.denyActor(ctx, http.StatusForbidden, "Creating boxes is not allowed for this user")
}
if !actor.User.Permissions.CanUploadFile {
return nil, app.denyActor(ctx, http.StatusForbidden, "Uploading files is not allowed for this user")
}
return actor, true
}
func (app *App) handleUserLogin(ctx *gin.Context) {
var payload struct {
APIKey string `json:"api_key"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid login payload"})
return
}
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
user, key, ok := app.userStore.FindByAPIKey(payload.APIKey)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
return
}
if user.Status != userstore.StatusActive {
ctx.JSON(http.StatusForbidden, gin.H{"error": "User account is disabled"})
return
}
if !user.Permissions.CanUseWeb {
ctx.JSON(http.StatusForbidden, gin.H{"error": "Web access is not allowed for this user"})
return
}
app.userStore.TouchAPIKey(user.ID, key.ID)
expiresAt := time.Now().UTC().Add(time.Duration(app.config.SessionTTLSeconds) * time.Second)
ctx.SetCookie(userSessionCookie, app.signSessionToken(user.ID, expiresAt), int(app.config.SessionTTLSeconds), "/", "", false, true)
ctx.JSON(http.StatusOK, gin.H{"ok": true, "user": gin.H{"id": user.ID, "email": user.Email, "username": user.Username}})
}
func (app *App) handleUserLogout(ctx *gin.Context) {
ctx.SetCookie(userSessionCookie, "", -1, "/", "", false, true)
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
func (app *App) handleUserMe(ctx *gin.Context) {
actor, ok := app.resolveActor(ctx)
if !ok || actor == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
ctx.JSON(http.StatusOK, gin.H{"user": toAdminUserView(actor.User)})
}
func (app *App) handleSelfCreateAPIKey(ctx *gin.Context) {
actor, ok := app.resolveActor(ctx)
if !ok || actor == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
if actor.User.Status != userstore.StatusActive {
ctx.JSON(http.StatusForbidden, gin.H{"error": "User account is disabled"})
return
}
var payload struct {
Name string `json:"name"`
}
_ = ctx.ShouldBindJSON(&payload)
key, raw, err := app.userStore.CreateAPIKey(actor.User.ID, payload.Name)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true, "api_key": raw, "key": toAdminAPIKeyView(key)})
}

View File

@@ -3,6 +3,7 @@ package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -28,6 +29,10 @@ func (app *App) requireGuestUploads(ctx *gin.Context) bool {
}
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
return app.validateCreateBoxRequestForActor(request, nil)
}
func (app *App) validateCreateBoxRequestForActor(request *models.CreateBoxRequest, actor *requestActor) error {
if request == nil {
return nil
}
@@ -44,19 +49,23 @@ func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error
totalSize := int64(0)
for _, file := range request.Files {
if err := app.validateFileSize(file.Size); err != nil {
if err := app.validateFileSizeForActor(file.Size, actor); err != nil {
return err
}
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
return app.validateBoxSizeForActor(totalSize, actor)
}
func (app *App) validateIncomingFile(boxID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return app.validateIncomingFileForActor(boxID, size, nil)
}
func (app *App) validateIncomingFileForActor(boxID string, size int64, actor *requestActor) error {
if err := app.validateFileSizeForActor(size, actor); err != nil {
return err
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
if app.effectiveMaxBoxBytes(actor) <= 0 {
return nil
}
@@ -68,23 +77,27 @@ func (app *App) validateIncomingFile(boxID string, size int64) error {
for _, file := range files {
totalSize += file.Size
}
return app.validateBoxSize(totalSize)
return app.validateBoxSizeForActor(totalSize, actor)
}
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
if err := app.validateFileSize(size); err != nil {
return app.validateManifestFileUploadForActor(boxID, fileID, size, nil)
}
func (app *App) validateManifestFileUploadForActor(boxID string, fileID string, size int64, actor *requestActor) error {
if err := app.validateFileSizeForActor(size, actor); err != nil {
return err
}
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return app.validateIncomingFile(boxID, size)
return app.validateIncomingFileForActor(boxID, size, actor)
}
if boxstore.IsExpired(manifest) {
_ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired")
}
if app.config.GlobalMaxBoxSizeBytes <= 0 {
if app.effectiveMaxBoxBytes(actor) <= 0 {
return nil
}
totalSize := int64(0)
@@ -100,24 +113,54 @@ func (app *App) validateManifestFileUpload(boxID string, fileID string, size int
if !found {
totalSize += size
}
return app.validateBoxSize(totalSize)
return app.validateBoxSizeForActor(totalSize, actor)
}
func (app *App) validateFileSize(size int64) error {
return app.validateFileSizeForActor(size, nil)
}
func (app *App) effectiveMaxFileBytes(actor *requestActor) int64 {
if actor == nil {
return app.config.GlobalMaxFileSizeBytes
}
return actor.User.Limits.MaxFileSizeBytes
}
func (app *App) effectiveMaxBoxBytes(actor *requestActor) int64 {
if actor == nil {
return app.config.GlobalMaxBoxSizeBytes
}
return actor.User.Limits.MaxBoxSizeBytes
}
func (app *App) validateFileSizeForActor(size int64, actor *requestActor) error {
if size < 0 {
return fmt.Errorf("File size cannot be negative")
}
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes {
limit := app.effectiveMaxFileBytes(actor)
if limit > 0 && size > limit {
if actor != nil {
return fmt.Errorf("File exceeds this account's max file size")
}
return fmt.Errorf("File exceeds the global max file size")
}
return nil
}
func (app *App) validateBoxSize(size int64) error {
return app.validateBoxSizeForActor(size, nil)
}
func (app *App) validateBoxSizeForActor(size int64, actor *requestActor) error {
if size < 0 {
return fmt.Errorf("Box size cannot be negative")
}
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes {
limit := app.effectiveMaxBoxBytes(actor)
if limit > 0 && size > limit {
if actor != nil {
return fmt.Errorf("Box exceeds this account's max box size")
}
return fmt.Errorf("Box exceeds the global max box size")
}
return nil
@@ -136,7 +179,11 @@ func (app *App) rejectExpiredManifestBox(boxID string) error {
}
func (app *App) limitRequestBody(ctx *gin.Context) {
limit := app.maxRequestBodyBytes()
app.limitRequestBodyForActor(ctx, nil)
}
func (app *App) limitRequestBodyForActor(ctx *gin.Context, actor *requestActor) {
limit := app.maxRequestBodyBytesForActor(actor)
if limit <= 0 {
return
}
@@ -144,12 +191,53 @@ func (app *App) limitRequestBody(ctx *gin.Context) {
}
func (app *App) maxRequestBodyBytes() int64 {
limit := app.config.GlobalMaxBoxSizeBytes
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
limit = app.config.GlobalMaxFileSizeBytes
return app.maxRequestBodyBytesForActor(nil)
}
func (app *App) maxRequestBodyBytesForActor(actor *requestActor) int64 {
limit := app.effectiveMaxBoxBytes(actor)
fileLimit := app.effectiveMaxFileBytes(actor)
if limit <= 0 || fileLimit > limit {
limit = fileLimit
}
if limit <= 0 {
return 0
}
return limit + 10*1024*1024
}
func (app *App) enforceUploadRateLimit(ctx *gin.Context, size int64) bool {
if !app.securityFeaturesEnabled() || app.securityGuard == nil {
return true
}
ip := app.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
}

369
lib/userstore/store.go Normal file
View File

@@ -0,0 +1,369 @@
package userstore
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"warpbox/lib/helpers"
)
const (
StatusActive = "active"
StatusDisabled = "disabled"
)
type Permissions struct {
CanUseWeb bool `json:"can_use_web"`
CanUseAPI bool `json:"can_use_api"`
CanCreateBox bool `json:"can_create_box"`
CanUploadFile bool `json:"can_upload_file"`
}
type Limits struct {
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
}
type APIKey struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
KeyHash string `json:"key_hash"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Status string `json:"status"`
Permissions Permissions `json:"permissions"`
Limits Limits `json:"limits"`
APIKeys []APIKey `json:"api_keys"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
}
type diskState struct {
Users []User `json:"users"`
}
type Store struct {
path string
mu sync.RWMutex
users map[string]User
}
func NewStore(path string) (*Store, error) {
s := &Store{path: path, users: map[string]User{}}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
s.mu.Lock()
defer s.mu.Unlock()
bytes, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if len(bytes) == 0 {
return nil
}
var state diskState
if err := json.Unmarshal(bytes, &state); err != nil {
return err
}
for _, user := range state.Users {
s.users[user.ID] = user
}
return nil
}
func (s *Store) saveLocked() error {
state := diskState{Users: make([]User, 0, len(s.users))}
for _, user := range s.users {
state.Users = append(state.Users, user)
}
sort.Slice(state.Users, func(i, j int) bool {
return state.Users[i].CreatedAt.After(state.Users[j].CreatedAt)
})
bytes, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return err
}
tmpPath := s.path + ".tmp"
if err := os.WriteFile(tmpPath, bytes, 0644); err != nil {
return err
}
return os.Rename(tmpPath, s.path)
}
func (s *Store) List() []User {
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]User, 0, len(s.users))
for _, user := range s.users {
users = append(users, user)
}
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.After(users[j].CreatedAt)
})
return users
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case StatusDisabled:
return StatusDisabled
default:
return StatusActive
}
}
func normalizePermissions(p Permissions) Permissions {
return Permissions{
CanUseWeb: p.CanUseWeb,
CanUseAPI: p.CanUseAPI,
CanCreateBox: p.CanCreateBox,
CanUploadFile: p.CanUploadFile,
}
}
func normalizeEmail(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeUsername(value string) string {
return strings.TrimSpace(value)
}
func validateUserInput(username string, email string) error {
if normalizeUsername(username) == "" {
return fmt.Errorf("username is required")
}
if normalizeEmail(email) == "" || !strings.Contains(email, "@") {
return fmt.Errorf("valid email is required")
}
return nil
}
func (s *Store) Create(username string, email string, permissions Permissions, limits Limits, status string) (User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := validateUserInput(username, email); err != nil {
return User{}, err
}
normEmail := normalizeEmail(email)
for _, existing := range s.users {
if strings.EqualFold(existing.Email, normEmail) {
return User{}, fmt.Errorf("email already exists")
}
}
id, err := helpers.RandomHexID(8)
if err != nil {
return User{}, err
}
now := time.Now().UTC()
user := User{
ID: "u_" + id,
Username: normalizeUsername(username),
Email: normEmail,
Status: normalizeStatus(status),
Permissions: normalizePermissions(permissions),
Limits: limits,
APIKeys: []APIKey{},
CreatedAt: now,
UpdatedAt: now,
}
s.users[user.ID] = user
if err := s.saveLocked(); err != nil {
return User{}, err
}
return user, nil
}
func (s *Store) Update(id string, username string, email string, permissions Permissions, limits Limits, status string) (User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := validateUserInput(username, email); err != nil {
return User{}, err
}
user, ok := s.users[id]
if !ok {
return User{}, fmt.Errorf("user not found")
}
normEmail := normalizeEmail(email)
for _, existing := range s.users {
if existing.ID == id {
continue
}
if strings.EqualFold(existing.Email, normEmail) {
return User{}, fmt.Errorf("email already exists")
}
}
user.Username = normalizeUsername(username)
user.Email = normEmail
user.Status = normalizeStatus(status)
user.Permissions = normalizePermissions(permissions)
user.Limits = limits
user.UpdatedAt = time.Now().UTC()
s.users[id] = user
if err := s.saveLocked(); err != nil {
return User{}, err
}
return user, nil
}
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[id]; !ok {
return fmt.Errorf("user not found")
}
delete(s.users, id)
return s.saveLocked()
}
func (s *Store) FindByID(id string) (User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[id]
return user, ok
}
func hashKey(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])
}
func (s *Store) CreateAPIKey(userID string, name string) (APIKey, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return APIKey{}, "", fmt.Errorf("user not found")
}
if strings.TrimSpace(name) == "" {
name = "default"
}
rawSuffix, err := helpers.RandomHexID(20)
if err != nil {
return APIKey{}, "", err
}
keyValue := "wbk_" + rawSuffix
id, err := helpers.RandomHexID(8)
if err != nil {
return APIKey{}, "", err
}
prefix := keyValue
if len(prefix) > 12 {
prefix = prefix[:12]
}
now := time.Now().UTC()
key := APIKey{
ID: "k_" + id,
Name: strings.TrimSpace(name),
Prefix: prefix,
KeyHash: hashKey(keyValue),
CreatedAt: now,
}
user.APIKeys = append(user.APIKeys, key)
user.UpdatedAt = now
s.users[userID] = user
if err := s.saveLocked(); err != nil {
return APIKey{}, "", err
}
return key, keyValue, nil
}
func (s *Store) RevokeAPIKey(userID string, keyID string) error {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return fmt.Errorf("user not found")
}
now := time.Now().UTC()
for i := range user.APIKeys {
if user.APIKeys[i].ID == keyID {
user.APIKeys[i].RevokedAt = &now
user.UpdatedAt = now
s.users[userID] = user
return s.saveLocked()
}
}
return fmt.Errorf("api key not found")
}
func (s *Store) FindByAPIKey(raw string) (User, APIKey, bool) {
h := hashKey(strings.TrimSpace(raw))
s.mu.RLock()
defer s.mu.RUnlock()
for _, user := range s.users {
for _, key := range user.APIKeys {
if key.RevokedAt != nil {
continue
}
if key.KeyHash == h {
return user, key, true
}
}
}
return User{}, APIKey{}, false
}
func (s *Store) TouchAPIKey(userID string, keyID string) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return
}
now := time.Now().UTC()
for i := range user.APIKeys {
if user.APIKeys[i].ID == keyID {
user.APIKeys[i].LastUsedAt = &now
break
}
}
user.LastSeenAt = &now
user.UpdatedAt = now
s.users[userID] = user
_ = s.saveLocked()
}
func (s *Store) TouchUser(userID string) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return
}
now := time.Now().UTC()
user.LastSeenAt = &now
user.UpdatedAt = now
s.users[userID] = user
_ = s.saveLocked()
}

14
run.sh
View File

@@ -7,6 +7,7 @@ if [ -f .env ]; then
fi
# Core service switches.
export WARPBOX_ENV="${WARPBOX_ENV:-development}"
export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}"
export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}"
export WARPBOX_ZIP_DOWNLOADS_ENABLED="${WARPBOX_ZIP_DOWNLOADS_ENABLED:-true}"
@@ -25,6 +26,19 @@ export WARPBOX_MAX_GUEST_EXPIRY_SECONDS="${WARPBOX_MAX_GUEST_EXPIRY_SECONDS:-172
export WARPBOX_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
export WARPBOX_ACTIVITY_RETENTION_SECONDS="${WARPBOX_ACTIVITY_RETENTION_SECONDS:-604800}"
export WARPBOX_SECURITY_ENABLED="${WARPBOX_SECURITY_ENABLED:-true}"
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}"
export WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS="${WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS:-300}"
# Data location.
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

@@ -111,6 +111,18 @@
.alerts-scroll { height: 326px; }
.boxes-scroll { height: 352px; }
.activity-scroll { height: 326px; }
.dashboard-empty-state {
margin: 8px;
padding: 10px;
color: #333333;
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 */
.alert-list { display: grid; min-width: 0; }

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

@@ -0,0 +1,100 @@
.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-danger { color: #7a0000; }
.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-table-toolbar { display: grid; grid-template-columns: 1fr 180px; gap: 8px; margin-bottom: 8px; }
.security-bans-wrap { height: 260px; min-height: 260px; }
.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; }
.security-docs h4 { margin: 4px 0; font-size: 13px; }
.security-docs p { margin: 0 0 8px; font-size: 12px; line-height: 1.4; }
.security-docs pre {
margin: 0 0 10px;
padding: 8px;
background: #f2f2f2;
border: 1px solid #c0c0c0;
overflow: auto;
font-size: 11px;
}
@media (max-width: 980px) {
.security-grid,
.security-ban-grid,
.security-table-toolbar {
grid-template-columns: 1fr;
}
}

View File

@@ -1,5 +1,5 @@
.upload-statusbar {
grid-template-columns: 1fr 100px;
grid-template-columns: minmax(0, 1fr) auto auto;
}
.side-stack {

View File

@@ -1,6 +1,7 @@
.users-page-body {
display: grid;
gap: 10px;
align-items: start;
}
.users-hero {
@@ -69,11 +70,92 @@
.users-main-grid {
display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
grid-template-columns: 320px minmax(0, 1fr);
gap: 10px;
min-height: 0;
}
.users-control-panel {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
align-self: start;
}
.users-selected-card {
display: grid;
gap: 4px;
padding: 8px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-selected-card span,
.users-selected-card small {
color: #444444;
font-size: 12px;
line-height: 14px;
}
.users-selected-card strong {
min-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
line-height: 18px;
}
.users-side-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.users-tab {
min-height: 28px;
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-family: inherit;
font-size: 12px;
line-height: 12px;
}
.users-tab.is-active {
color: #ffffff;
background: #000078;
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
}
.users-tab-panel {
display: none;
min-height: 0;
padding: 8px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-tab-panel.is-active {
display: grid;
gap: 8px;
align-content: start;
}
.users-panel {
min-height: 0;
display: flex;
@@ -97,6 +179,11 @@
border-bottom: 1px solid #b0b0b0;
}
.users-panel-header.compact {
margin: -8px -8px 0;
min-height: 30px;
}
.users-panel-title {
display: flex;
align-items: center;
@@ -194,13 +281,13 @@
.users-toolbar-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
grid-template-columns: minmax(220px, 1.2fr) repeat(3, minmax(100px, .6fr));
gap: 8px;
}
.users-table-wrap {
min-height: 420px;
height: 420px;
min-height: 360px;
height: min(54vh, 520px);
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
@@ -239,6 +326,7 @@
.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-table tbody tr.is-selected { background: #c8d8ff; }
.users-col-check { width: 30px; }
.users-col-actions { width: 136px; }
@@ -301,6 +389,70 @@
line-height: 12px;
}
.users-empty-note {
margin: 0;
color: #555555;
font-size: 12px;
line-height: 15px;
}
.users-key-reveal {
display: grid;
gap: 4px;
padding: 6px;
background: #ffffcc;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-key-reveal span {
font-size: 12px;
line-height: 12px;
}
.users-key-list {
display: grid;
gap: 6px;
}
.users-key-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px;
background: #f6f6f6;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
}
.users-key-row div {
min-width: 0;
display: grid;
gap: 2px;
}
.users-key-row strong,
.users-key-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.users-key-row span {
color: #555555;
font-size: 11px;
line-height: 12px;
}
.users-key-row.is-revoked {
opacity: .62;
}
@media (max-width: 1024px) {
.users-main-grid,
.users-hero {

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

@@ -0,0 +1,106 @@
(() => {
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();
const initialQuery = new URLSearchParams(window.location.search).get("q");
if (initialQuery) searchInput.value = initialQuery;
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();
})();

View File

@@ -1,25 +1,16 @@
(() => {
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 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 alertsBody = document.getElementById("alerts-body");
const selectedCountEl = document.getElementById("selected-count");
const openCountEl = document.querySelector("[data-open-count]");
const highCountEl = document.querySelector("[data-high-count]");
const ackCountEl = document.querySelector("[data-ack-count]");
const closedCountEl = document.querySelector("[data-closed-count]");
const selectAll = document.getElementById("select-all");
const selectedCountEl = document.getElementById("selected-count");
const totalPill = document.getElementById("alerts-total-pill");
const toast = document.getElementById("toast");
const detailEls = {
title: document.getElementById("detail-title"),
@@ -32,185 +23,245 @@
metadata: document.getElementById("detail-metadata")
};
if (!alertsBody || !searchInput || !statusFilter || !selectedCountEl) return;
if (!dataNode || !alertsBody) return;
const state = {
alerts: parseData(),
selected: new Set(),
activeID: null
};
const initialQuery = new URLSearchParams(window.location.search).get("q");
if (initialQuery) searchInput.value = initialQuery;
function parseData() {
try {
return JSON.parse(dataNode.textContent || "[]");
} catch (_) {
return [];
}
}
function showToast(message, type = "info", duration = 1800) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast, duration });
return;
}
if (!toast) return;
toast.textContent = message;
toast.classList.add("is-visible");
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
}
function allRows() {
return Array.from(alertsBody.querySelectorAll("tr"));
function createdLabel(value) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return "-";
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
}
function visibleRows() {
return allRows().filter((row) => row.style.display !== "none");
function allAlerts() {
return state.alerts.slice();
}
function selectedRows() {
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
}
function updateSelectedCount() {
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
}
function updateSummaryCounts() {
const rows = visibleRows();
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
}
function updateDetails(row) {
if (!row) return;
allRows().forEach((item) => item.classList.remove("is-selected"));
row.classList.add("is-selected");
detailEls.title.textContent = row.dataset.title || "";
detailEls.severity.textContent = row.dataset.severity || "";
detailEls.status.textContent = row.dataset.status || "";
detailEls.code.textContent = row.dataset.code || "";
detailEls.trace.textContent = row.dataset.trace || "";
detailEls.time.textContent = row.dataset.time || "";
detailEls.description.textContent = row.dataset.description || "";
try {
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
} catch (_) {
detailEls.metadata.textContent = row.dataset.metadata || "{}";
}
}
function applyFilters() {
const search = searchInput.value.trim().toLowerCase();
function filteredAlerts() {
const query = searchInput.value.trim().toLowerCase();
const severity = severityFilter.value;
const status = statusFilter.value;
const group = sourceFilter.value;
allRows().forEach((row) => {
const rows = allAlerts().filter((alert) => {
const haystack = [
row.dataset.title,
row.dataset.description,
row.dataset.code,
row.dataset.trace,
row.dataset.group
alert.title,
alert.message,
alert.code,
alert.trace,
alert.group
].join(" ").toLowerCase();
const matchesSearch = !search || haystack.includes(search);
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
const matchesStatus = status === "all" || row.dataset.status === status;
const matchesGroup = group === "all" || row.dataset.group === group;
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
const 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 };
visibleRows().sort((a, b) => {
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
return Number(b.dataset.id) - Number(a.dataset.id);
}).forEach((row) => alertsBody.appendChild(row));
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
updateSelectedCount();
updateSummaryCounts();
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 setRowStatus(row, nextStatus) {
row.dataset.status = nextStatus;
const statusCell = row.children[3]?.querySelector(".alerts-pill");
if (!statusCell) return;
statusCell.className = `alerts-pill ${nextStatus}`;
statusCell.textContent = nextStatus;
function 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 changeSelectedStatus(nextStatus) {
const rows = selectedRows();
if (!rows.length) {
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;
}
rows.forEach((row) => {
setRowStatus(row, nextStatus);
row.querySelector(".row-check").checked = false;
});
if (selectAll) selectAll.checked = false;
updateSelectedCount();
updateSummaryCounts();
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
if (currentRow) updateDetails(currentRow);
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
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");
}
const commandMessages = {
refresh: "Alerts refreshed in mock view",
export: "Visible alerts exported in mock view",
"copy-meta": "Metadata copied in mock view",
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
"help-meta": "Metadata explains why the alert happened and includes extra context."
};
function runCommand(command) {
switch (command) {
case "ack":
changeSelectedStatus("acked");
return;
case "close":
changeSelectedStatus("closed");
return;
case "open-only":
statusFilter.value = "open";
applyFilters();
showToast("Showing open alerts only");
return;
default:
showToast(commandMessages[command] || `Mock action: ${command}`);
}
function escapeHtml(value) {
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
}
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
});
allRows().forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("button") || event.target.closest("input")) return;
updateDetails(row);
});
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render);
});
selectAll?.addEventListener("change", () => {
visibleRows().forEach((row) => {
const checkbox = row.querySelector(".row-check");
if (checkbox) checkbox.checked = selectAll.checked;
const rows = filteredAlerts();
rows.forEach((alert) => {
if (selectAll.checked) state.selected.add(alert.id);
else state.selected.delete(alert.id);
});
updateSelectedCount();
render();
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
button.addEventListener("click", async () => {
menuController.close();
runCommand(button.dataset.command);
try {
await runAction(button.dataset.command);
} catch (error) {
showToast(error.message, "error", 3200);
}
});
});
document.addEventListener("keydown", (event) => {
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
await runAction("refresh");
}
});
applyFilters();
updateDetails(allRows()[0]);
render();
})();

View File

@@ -373,6 +373,35 @@
}
}
async function runCleanupAction() {
try {
const response = await fetch("/admin/boxes/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "cleanup_expired", box_ids: [] })
});
const payload = await response.json();
if (!response.ok) {
const message = payload.error || payload.message || "Cleanup failed";
showToast(message, "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 || "Expired cleanup completed";
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", 3200);
} catch (_) {
showToast("Network error while running cleanup", "error", 3200);
}
}
function selectedIDsOrActive() {
if (state.selected.size) return Array.from(state.selected);
const active = currentActiveBox();
@@ -419,6 +448,10 @@
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
await runBulkAction("delete", selectedIDsOrActive());
return;
case "cleanup-expired":
if (!window.confirm("Run cleanup for expired boxes now?")) return;
await runCleanupAction();
return;
case "help-scope":
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
return;

View File

@@ -7,6 +7,7 @@
});
}
};
const dataNode = document.getElementById("dashboard-data");
const toast = document.getElementById("toast");
const statusText = document.getElementById("statusText");
const modal = document.querySelector("[data-alert-modal]");
@@ -19,18 +20,28 @@
const topAlertChip = document.getElementById("topAlertChip");
const topTaskbar = document.querySelector(".admin-taskbar");
const dashboardData = parseDashboardData();
if (!statusText || !alertsCard || !topAlertChip) return;
function showToast(message, type = "info") {
function parseDashboardData() {
try {
return JSON.parse(dataNode?.textContent || "{}");
} catch (_) {
return {};
}
}
function showToast(message, type = "info", duration = 2200) {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast });
window.WarpBoxUI.toast(message, type, { target: toast, duration });
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);
showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), duration);
}
function setStatus(message) {
@@ -91,45 +102,159 @@
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 downloadFile(filename, content, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
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();
function csvEscape(value) {
const text = String(value ?? "");
if (!/[",\n]/.test(text)) return text;
return `"${text.replaceAll('"', '""')}"`;
}
function exportBoxesCSV() {
const rows = dashboardData.boxes || [];
const header = ["id", "status", "files", "size", "created", "expires", "flags"];
const lines = rows.map((box) => [
box.id,
box.status_label,
`${box.complete_files}/${box.file_count}`,
box.total_size_label,
box.created_at_label,
box.expires_at_label,
(box.flags || []).join("|")
].map(csvEscape).join(","));
downloadFile(`warpbox-dashboard-boxes-${new Date().toISOString().replaceAll(":", "-")}.csv`, [header.join(","), ...lines].join("\n"), "text/csv;charset=utf-8");
showToast("Dashboard boxes exported", "success");
}
function exportAlertsJSON() {
downloadFile(`warpbox-dashboard-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`, JSON.stringify(dashboardData.alerts || [], null, 2), "application/json;charset=utf-8");
showToast("Dashboard alerts exported", "success");
}
function exportSnapshot() {
downloadFile(`warpbox-dashboard-${new Date().toISOString().replaceAll(":", "-")}.json`, JSON.stringify(dashboardData, null, 2), "application/json;charset=utf-8");
showToast("Dashboard snapshot exported", "success");
}
async function postAlertAction(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 || "Alert action failed");
return payload;
}
async function postBoxAction(action, extra = {}) {
const response = await fetch("/admin/boxes/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...extra })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "Box action failed");
return payload;
}
async function closeAlert(row) {
const id = row?.dataset.alertId;
if (!id) return;
await postAlertAction("close", [id]);
row.classList.add("is-dismissed");
updateAlertSummary();
showToast(`Closed alert ${row.dataset.alertCode || id}`, "success");
setStatus(`Closed alert ${row.dataset.alertCode || id}`);
}
async function closeLowAlerts() {
const rows = Array.from(document.querySelectorAll('.alert-row[data-severity="low"]'));
const ids = rows.map((row) => row.dataset.alertId).filter(Boolean);
if (!ids.length) {
showToast("No low alerts to close");
return;
}
if (command === "show-all-boxes") window.location.hash = "recent-boxes";
if (command === "show-all-alerts") window.location.hash = "alerts";
await postAlertAction("close", ids);
rows.forEach((row) => row.classList.add("is-dismissed"));
updateAlertSummary();
showToast(`Closed ${ids.length} low alert(s)`, "success");
setStatus(`Closed ${ids.length} low alert(s)`);
}
const message = commandMessages[command] || `Command: ${command}`;
showToast(message);
setStatus(message);
async function cleanupExpiredBoxes() {
if (!window.confirm("Clean up expired boxes now? This can delete expired box data.")) return;
const payload = await postBoxAction("cleanup_expired");
showToast(payload.message || "Expired cleanup complete", payload.ok ? "success" : "warning", 3200);
setStatus(payload.message || "Expired cleanup complete");
window.setTimeout(() => window.location.reload(), 900);
}
async function runCommand(command) {
if (command === "refresh") {
window.location.reload();
return;
}
if (command === "dashboard-snapshot") return exportSnapshot();
if (command === "logout") {
window.location.href = "/admin/logout";
return;
}
if (command === "compact-mode") {
document.body.classList.toggle("is-compact");
showToast("Toggled compact density");
return;
}
if (command === "show-all-boxes") {
window.location.href = "/admin/boxes";
return;
}
if (command === "show-all-alerts") {
window.location.href = "/admin/alerts";
return;
}
if (command === "open-users") {
window.location.href = "/admin/users";
return;
}
if (command === "open-activity") {
window.location.href = "/admin/activity";
return;
}
if (command === "open-settings") {
window.location.href = "/admin/settings";
return;
}
if (command === "export-boxes") return exportBoxesCSV();
if (command === "export-alerts") return exportAlertsJSON();
if (command === "close-low-alerts") return closeLowAlerts();
if (command === "cleanup-expired") return cleanupExpiredBoxes();
if (command === "shortcuts") {
showToast("Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.", "info", 3600);
return;
}
if (command === "about") {
showToast("Live WarpBox admin dashboard backed by alerts, activity, boxes, users, and settings.", "info", 3600);
}
}
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
button.addEventListener("click", async () => {
menuController.close();
runCommand(button.dataset.command);
try {
await runCommand(button.dataset.command);
} catch (error) {
showToast(error.message || "Command failed", "error", 3600);
setStatus(error.message || "Command failed");
}
});
});
@@ -150,37 +275,39 @@
} catch (_) {
meta = row?.dataset.alertMeta || "{}";
}
openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta);
openModal(`${title} (${row?.dataset.alertCode || "alert"})`, 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.querySelectorAll("[data-close-alert]").forEach((button) => {
button.addEventListener("click", async () => {
try {
await closeAlert(button.closest(".alert-row"));
} catch (error) {
showToast(error.message || "Could not close alert", "error", 3600);
}
});
});
document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal);
backdrop?.addEventListener("click", closeModal);
topAlertChip.addEventListener("click", (event) => {
event.preventDefault();
scrollToSection("alerts");
if (document.getElementById("alerts")) {
event.preventDefault();
scrollToSection("alerts");
}
});
window.addEventListener("scroll", updateStickyHeader, { passive: true });
document.addEventListener("keydown", (event) => {
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") {
menuController.close();
closeModal();
}
if (event.key === "F5") {
event.preventDefault();
runCommand("refresh");
await runCommand("refresh");
}
if (event.altKey && event.key.toLowerCase() === "a") {
event.preventDefault();

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

@@ -0,0 +1,314 @@
(() => {
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 filterInput = document.getElementById("security-ban-filter");
const sortSelect = document.getElementById("security-ban-sort");
const selectAll = document.getElementById("security-select-all");
const copyIPButton = document.getElementById("security-copy-ip");
const openActivityButton = document.getElementById("security-open-activity");
const openAlertsButton = document.getElementById("security-open-alerts");
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"),
why: document.getElementById("security-detail-why")
};
if (!eventsNode || !alertsNode || !bansNode) return;
const state = {
events: parse(eventsNode),
alerts: parse(alertsNode),
bans: parse(bansNode),
selectedIP: "",
selectedIPs: new Set()
};
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 rowData() {
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
const filter = String(filterInput?.value || "").trim().toLowerCase();
let rows = state.bans.map((entry) => ({ ip: entry.ip, status: "banned", until: entry.until, ban: entry }));
if (filter) rows = rows.filter((row) => row.ip.toLowerCase().includes(filter));
const sort = sortSelect?.value || "expiry_asc";
rows.sort((a, b) => {
if (sort === "ip_asc") return a.ip.localeCompare(b.ip);
if (sort === "ip_desc") return b.ip.localeCompare(a.ip);
const av = new Date(a.until).getTime();
const bv = new Date(b.until).getTime();
return sort === "expiry_desc" ? bv - av : av - bv;
});
return { rows, banMap };
}
function renderBans() {
bansBody.innerHTML = "";
const { rows } = rowData();
rows.forEach((rowData) => {
const row = document.createElement("tr");
row.className = "security-bans-body-row";
if (rowData.ip === state.selectedIP) row.classList.add("is-selected");
row.innerHTML = `
<td><input type="checkbox" class="security-row-select" data-ip="${escapeHtml(rowData.ip)}" ${state.selectedIPs.has(rowData.ip) ? "checked" : ""}></td>
<td>${escapeHtml(rowData.ip || "-")}</td>
<td>${rowData.status}</td>
<td>${createdLabel(rowData.until)}</td>
`;
row.addEventListener("click", (event) => {
if (event.target && event.target.classList.contains("security-row-select")) return;
setSelectedIP(rowData.ip);
});
bansBody.appendChild(row);
});
bansBody.querySelectorAll(".security-row-select").forEach((checkbox) => {
checkbox.addEventListener("change", () => {
const ip = checkbox.getAttribute("data-ip");
if (!ip) return;
if (checkbox.checked) state.selectedIPs.add(ip); else state.selectedIPs.delete(ip);
});
});
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 = "GeoIP not enabled yet";
detail.asn.textContent = "GeoIP not enabled yet";
detail.until.textContent = "-";
detail.why.textContent = "-";
return;
}
const ban = state.bans.find((entry) => entry.ip === ip);
const matchingEvents = state.events.filter((event) => String(event.ip || "") === ip);
const matchingAlerts = state.alerts.filter((alert) => String(alert?.meta?.ip || "") === ip);
const lastEvent = matchingEvents[0] || null;
detail.ip.textContent = ip;
detail.risk.textContent = ban ? "high" : "medium";
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
detail.geo.textContent = "GeoIP not enabled yet";
detail.asn.textContent = "GeoIP not enabled yet";
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned";
detail.why.textContent = `${matchingEvents.length} events, ${matchingAlerts.length} alerts${lastEvent ? `, latest=${lastEvent.kind}` : ""}`;
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) return showToast("Enter IP first", "warning");
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) return showToast("Enter IP first", "warning");
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
if (!banUntil) return showToast("Set valid expiration date", "warning");
if (!window.confirm("Apply custom ban expiration?")) 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) return showToast("Select or enter IP first", "warning");
if (!window.confirm(`Unban ${ip}?`)) return;
const payload = await postAction("unban", { ip });
state.selectedIPs.delete(ip);
setSelectedIP("");
showToast(payload.message || "IP unbanned", "success");
}
async function bulkUnban() {
const ips = Array.from(state.selectedIPs);
if (ips.length === 0) return showToast("Select at least one banned IP", "warning");
if (!window.confirm(`Unban ${ips.length} selected IPs?`)) return;
const payload = await postAction("bulk_unban", { ips });
state.selectedIPs.clear();
setSelectedIP("");
showToast(payload.message || "Bulk unban complete", "success");
}
async function unbanAll() {
if (!window.confirm("Unban all active bans?")) return;
const payload = await postAction("unban_all");
state.selectedIPs.clear();
setSelectedIP("");
showToast(payload.message || "All bans cleared", "success");
}
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
try {
const command = button.dataset.command;
if (command === "refresh") return window.location.reload();
if (command === "ban-ip") return await banIP();
if (command === "ban-until") return await banUntil();
if (command === "unban-ip") return await unbanIP();
if (command === "bulk-unban") return await bulkUnban();
if (command === "unban-all") return await unbanAll();
} catch (error) {
showToast(error.message, "error", 3200);
} finally {
renderBans();
renderIPDetails();
}
});
});
ipInput.addEventListener("input", () => renderIPDetails());
filterInput?.addEventListener("input", () => renderBans());
sortSelect?.addEventListener("change", () => renderBans());
selectAll?.addEventListener("change", () => {
if (selectAll.checked) state.bans.forEach((ban) => state.selectedIPs.add(ban.ip));
else state.selectedIPs.clear();
renderBans();
});
copyIPButton?.addEventListener("click", async () => {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) return showToast("No IP selected", "warning");
await navigator.clipboard.writeText(ip);
showToast("IP copied", "success");
});
openActivityButton?.addEventListener("click", () => {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) return showToast("No IP selected", "warning");
window.location.href = `/admin/activity?q=${encodeURIComponent(ip)}`;
});
openAlertsButton?.addEventListener("click", () => {
const ip = state.selectedIP || String(ipInput.value || "").trim();
if (!ip) return showToast("No IP selected", "warning");
window.location.href = `/admin/alerts?q=${encodeURIComponent(ip)}`;
});
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);
render();
})();

View File

@@ -3,8 +3,7 @@
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 statusFilter = document.getElementById("users-status");
const sort = document.getElementById("users-sort");
const size = document.getElementById("users-size");
const masterCheck = document.getElementById("users-master-check");
@@ -14,61 +13,234 @@
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");
const selectedName = document.getElementById("selected-user-name");
const selectedMeta = document.getElementById("selected-user-meta");
const addForm = document.getElementById("add-user-form");
const editForm = document.getElementById("edit-user-form");
const policiesForm = document.getElementById("policies-form");
const apiKeyForm = document.getElementById("api-key-form");
const apiKeyList = document.getElementById("api-key-list");
const apiKeyReveal = document.getElementById("api-key-reveal");
const apiKeyValue = document.getElementById("api-key-value");
if (!body || !search || !status || !role || !sort || !size) return;
if (!body || !search || !statusFilter || !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,
users: [],
selected: new Set(),
currentUserID: "",
};
const state = { page: 1, selected: new Set() };
const fields = {
add: {
username: document.getElementById("add-username"),
email: document.getElementById("add-email"),
status: document.getElementById("add-status"),
maxFile: document.getElementById("add-max-file"),
maxBox: document.getElementById("add-max-box"),
web: document.getElementById("add-perm-web"),
api: document.getElementById("add-perm-api"),
create: document.getElementById("add-perm-create"),
upload: document.getElementById("add-perm-upload"),
},
edit: {
username: document.getElementById("edit-username"),
email: document.getElementById("edit-email"),
status: document.getElementById("edit-status"),
save: document.getElementById("save-edit-button"),
delete: document.getElementById("delete-user-button"),
},
policies: {
maxFile: document.getElementById("policy-max-file"),
maxBox: document.getElementById("policy-max-box"),
web: document.getElementById("policy-perm-web"),
api: document.getElementById("policy-perm-api"),
create: document.getElementById("policy-perm-create"),
upload: document.getElementById("policy-perm-upload"),
save: document.getElementById("save-policies-button"),
},
keys: {
name: document.getElementById("api-key-name"),
create: document.getElementById("create-key-button"),
},
};
function toast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 });
return;
}
if (!toastTarget) return;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
if (toastTarget) toastTarget.textContent = message;
}
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;
});
function escapeHTML(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function api(path, method = "GET", payload = null) {
const response = await fetch(path, {
method,
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
let data = {};
try {
data = await response.json();
} catch (_) {}
if (!response.ok) throw new Error(data.error || "Request failed");
return data;
}
function selectedUser() {
return state.users.find((user) => user.id === state.currentUserID) || null;
}
const BYTES_PER_MB = 1024 * 1024;
function numericMB(input) {
const value = Number(input?.value || 0);
return Number.isFinite(value) && value > 0 ? String(Math.floor(value)) : "0";
}
function bytesToMB(value) {
const bytes = Number(value || 0);
return Number.isFinite(bytes) && bytes > 0 ? String(Math.ceil(bytes / BYTES_PER_MB)) : "0";
}
function limitLabelMB(value) {
const mb = Number(bytesToMB(value));
return mb > 0 ? `${mb} MB` : "unlimited";
}
function permissionPayload(source) {
return {
can_use_web: Boolean(source.web?.checked),
can_use_api: Boolean(source.api?.checked),
can_create_box: Boolean(source.create?.checked),
can_upload_file: Boolean(source.upload?.checked),
};
}
function payloadFromUser(user, overrides = {}) {
return {
id: user.id,
username: user.username,
email: user.email,
status: user.status,
max_file_size_mb: bytesToMB(user.limits?.max_file_size_bytes),
max_box_size_mb: bytesToMB(user.limits?.max_box_size_bytes),
permissions: user.permissions || {},
...overrides,
};
}
function setTab(tabName) {
document.querySelectorAll(".users-tab").forEach((tab) => {
tab.classList.toggle("is-active", tab.dataset.tab === tabName);
});
document.querySelectorAll(".users-tab-panel").forEach((panel) => {
panel.classList.toggle("is-active", panel.dataset.panel === tabName);
});
}
function setSelectedUser(userID, preferredTab = "edit") {
state.currentUserID = userID || "";
state.selected.clear();
if (userID) state.selected.add(userID);
populateSelectedPanels();
render();
if (preferredTab) setTab(preferredTab);
}
function setControlsEnabled(group, enabled) {
Object.values(group).forEach((element) => {
if (!element) return;
element.disabled = !enabled;
});
}
function populateSelectedPanels() {
const user = selectedUser();
const hasUser = Boolean(user);
selectedName.textContent = hasUser ? user.username : "None";
selectedMeta.textContent = hasUser ? `${user.email} · ${user.status}` : "Choose a row to edit policies and keys.";
setControlsEnabled(fields.edit, hasUser);
setControlsEnabled(fields.policies, hasUser);
setControlsEnabled(fields.keys, hasUser);
if (!hasUser) {
fields.edit.username.value = "";
fields.edit.email.value = "";
fields.edit.status.value = "active";
fields.policies.maxFile.value = "";
fields.policies.maxBox.value = "";
[fields.policies.web, fields.policies.api, fields.policies.create, fields.policies.upload].forEach((item) => { item.checked = false; });
fields.keys.name.value = "default";
apiKeyList.innerHTML = `<p class="users-empty-note">Select a user to manage API keys.</p>`;
apiKeyReveal.hidden = true;
return;
}
fields.edit.username.value = user.username || "";
fields.edit.email.value = user.email || "";
fields.edit.status.value = user.status || "active";
fields.policies.maxFile.value = bytesToMB(user.limits?.max_file_size_bytes);
fields.policies.maxBox.value = bytesToMB(user.limits?.max_box_size_bytes);
fields.policies.web.checked = Boolean(user.permissions?.can_use_web);
fields.policies.api.checked = Boolean(user.permissions?.can_use_api);
fields.policies.create.checked = Boolean(user.permissions?.can_create_box);
fields.policies.upload.checked = Boolean(user.permissions?.can_upload_file);
fields.keys.name.value = "default";
renderAPIKeys(user);
}
function renderAPIKeys(user) {
const keys = user.api_keys || [];
if (!keys.length) {
apiKeyList.innerHTML = `<p class="users-empty-note">No API keys yet.</p>`;
return;
}
apiKeyList.innerHTML = keys.map((key) => {
const revoked = Boolean(key.revoked_at);
return `
<div class="users-key-row ${revoked ? "is-revoked" : ""}">
<div><strong>${escapeHTML(key.name || "default")}</strong><span>${escapeHTML(key.prefix || key.id)}${revoked ? " · revoked" : ""}</span></div>
<button class="win98-button users-row-button" type="button" data-revoke-key="${escapeHTML(key.id)}" ${revoked ? "disabled" : ""}>Revoke</button>
</div>
`;
}).join("");
apiKeyList.querySelectorAll("[data-revoke-key]").forEach((button) => {
button.addEventListener("click", () => revokeAPIKey(button.dataset.revokeKey));
});
}
function renderStats() {
document.getElementById("stat-total").textContent = String(state.users.length);
document.getElementById("stat-active").textContent = String(state.users.filter((u) => u.status === "active").length);
document.getElementById("stat-keys").textContent = String(state.users.filter((u) => (u.api_key_count || 0) > 0).length);
document.getElementById("stat-disabled").textContent = String(state.users.filter((u) => u.status === "disabled").length);
}
function filteredUsers() {
const query = search.value.trim().toLowerCase();
const currentStatus = statusFilter.value;
const rows = state.users.filter((user) => {
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
const matchesStatus = currentStatus === "all" || user.status === currentStatus;
return matchesQuery && matchesStatus;
});
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;
if (sort.value === "createdDesc") return String(b.created_at).localeCompare(String(a.created_at));
if (sort.value === "lastSeenDesc") return String(b.last_seen_at || "").localeCompare(String(a.last_seen_at || ""));
if (sort.value === "keysDesc") return (b.api_key_count || 0) - (a.api_key_count || 0);
return a.username.localeCompare(b.username);
});
return rows;
@@ -77,40 +249,56 @@
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;
state.page = Math.max(1, Math.min(state.page, pages));
const start = (state.page - 1) * perPage;
return { rows: rows.slice(start, start + perPage), pages, start };
return { rows: rows.slice(start, start + perPage), pages };
}
function statusPill(value) {
return `<span class="users-pill ${value}">${value}</span>`;
function permissionsSummary(permissions = {}) {
const items = [];
if (permissions.can_use_web) items.push("web");
if (permissions.can_use_api) items.push("api");
if (permissions.can_create_box) items.push("create");
if (permissions.can_upload_file) items.push("upload");
return items.join(", ") || "none";
}
function limitsSummary(limits = {}) {
return `file ${limitLabelMB(limits.max_file_size_bytes)} / box ${limitLabelMB(limits.max_box_size_bytes)}`;
}
function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : "";
const active = user.id === state.currentUserID ? " is-selected" : "";
const row = document.createElement("tr");
row.className = active;
row.dataset.userId = user.id;
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>
<td><div class="users-username"><strong>${escapeHTML(user.username)}</strong><span class="users-muted">${escapeHTML(user.id)}</span></div></td>
<td title="${escapeHTML(user.email)}">${escapeHTML(user.email)}</td>
<td><span class="users-pill ${escapeHTML(user.status)}">${escapeHTML(user.status)}</span></td>
<td>${escapeHTML(permissionsSummary(user.permissions))}</td>
<td>${escapeHTML(limitsSummary(user.limits))}</td>
<td>${user.api_key_count || 0}</td>
<td>${escapeHTML(user.last_seen_at || "never")}</td>
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="edit">Edit</button><button class="win98-button users-row-button" type="button" data-action="keys">Keys</button></div></td>
`;
row.addEventListener("click", (event) => {
if (event.target.closest(".row-check") || event.target.closest("button")) return;
setSelectedUser(user.id, "edit");
});
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id);
if (event.target.checked) state.currentUserID = user.id;
populateSelectedPanels();
syncSelected();
syncMasterCheck();
render();
});
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
toast(`Mock user preview: ${user.username}`);
});
row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit"));
row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys"));
return row;
}
@@ -123,19 +311,11 @@
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 rows = filteredUsers();
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;
@@ -145,75 +325,109 @@
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;
async function fetchUsers() {
const result = await api("/admin/users/list");
state.users = result.users || [];
if (state.currentUserID && !state.users.some((user) => user.id === state.currentUserID)) {
state.currentUserID = "";
}
selected.forEach((user) => { user.status = nextStatus; });
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id)));
renderStats();
populateSelectedPanels();
render();
}
function runCommand(command) {
async function saveUser(payload, successMessage) {
const result = await api("/admin/users/save", "POST", payload);
state.currentUserID = result.user?.id || state.currentUserID;
state.selected.clear();
if (state.currentUserID) state.selected.add(state.currentUserID);
await fetchUsers();
toast(successMessage);
return result.user;
}
async function deleteUser(userID) {
const user = state.users.find((item) => item.id === userID);
if (!user) return;
if (!confirm(`Delete ${user.username}?`)) return;
await api("/admin/users/delete", "POST", { id: userID });
state.currentUserID = "";
state.selected.delete(userID);
await fetchUsers();
setTab("add");
toast("User deleted");
}
async function revokeAPIKey(keyID) {
const user = selectedUser();
if (!user || !keyID) return;
await api("/admin/users/api-keys/revoke", "POST", { user_id: user.id, key_id: keyID });
await fetchUsers();
setTab("keys");
toast("API key revoked");
}
async function runCommand(command) {
switch (command) {
case "invite":
modeInput.value = "invite";
toast("Invite mode selected");
break;
case "tab-add":
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");
setTab("add");
break;
case "refresh":
await fetchUsers();
toast("Users list refreshed");
render();
break;
case "pending-only":
status.value = "pending";
case "bulk-disable":
case "bulk-enable": {
const nextStatus = command === "bulk-disable" ? "disabled" : "active";
const ids = Array.from(state.selected);
if (!ids.length) {
toast("Select one or more users first", "warning");
return;
}
for (const id of ids) {
const user = state.users.find((item) => item.id === id);
if (!user) continue;
await api("/admin/users/save", "POST", payloadFromUser(user, { status: nextStatus }));
}
await fetchUsers();
toast(`Updated ${ids.length} user(s)`);
break;
}
case "bulk-delete": {
const ids = Array.from(state.selected);
if (!ids.length) {
toast("Select one or more users first", "warning");
return;
}
if (!confirm(`Delete ${ids.length} selected user(s)?`)) return;
for (const id of ids) await api("/admin/users/delete", "POST", { id });
state.selected.clear();
state.currentUserID = "";
await fetchUsers();
setTab("add");
toast(`Deleted ${ids.length} user(s)`);
break;
}
case "clear-filters":
search.value = "";
statusFilter.value = "all";
sort.value = "username";
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", () => {
document.querySelectorAll(".users-tab").forEach((tab) => {
tab.addEventListener("click", () => setTab(tab.dataset.tab));
});
[search, statusFilter, sort, size].forEach((element) => {
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1;
render();
});
@@ -231,74 +445,131 @@
masterCheck.addEventListener("change", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check");
if (!checkbox) return;
if (!checkbox || !userID) 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();
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
populateSelectedPanels();
render();
});
selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check");
const userID = row.querySelector(".users-muted")?.textContent || "";
if (!checkbox) return;
if (!checkbox || !userID) return;
checkbox.checked = true;
state.selected.add(userID);
});
syncSelected();
syncMasterCheck();
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
populateSelectedPanels();
render();
});
form.addEventListener("submit", (event) => {
addForm.addEventListener("submit", async (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const email = emailInput.value.trim();
const mode = modeInput.value;
const username = fields.add.username.value.trim();
const email = fields.add.email.value.trim();
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");
try {
await saveUser({
username,
email,
status: fields.add.status.value,
max_file_size_mb: numericMB(fields.add.maxFile),
max_box_size_mb: numericMB(fields.add.maxBox),
permissions: permissionPayload(fields.add),
}, "User created");
addForm.reset();
fields.add.status.value = "active";
fields.add.maxFile.value = "0";
fields.add.maxBox.value = "0";
[fields.add.web, fields.add.api, fields.add.create, fields.add.upload].forEach((item) => { item.checked = true; });
setTab("edit");
} catch (error) {
toast(error.message || "Could not create user", "warning");
}
});
editForm.addEventListener("submit", async (event) => {
event.preventDefault();
const user = selectedUser();
if (!user) return;
try {
await saveUser(payloadFromUser(user, {
username: fields.edit.username.value.trim(),
email: fields.edit.email.value.trim(),
status: fields.edit.status.value,
}), "User updated");
} catch (error) {
toast(error.message || "Could not update user", "warning");
}
});
fields.edit.delete.addEventListener("click", () => {
const user = selectedUser();
if (user) deleteUser(user.id);
});
policiesForm.addEventListener("submit", async (event) => {
event.preventDefault();
const user = selectedUser();
if (!user) return;
try {
await saveUser(payloadFromUser(user, {
max_file_size_mb: numericMB(fields.policies.maxFile),
max_box_size_mb: numericMB(fields.policies.maxBox),
permissions: permissionPayload(fields.policies),
}), "Policies updated");
} catch (error) {
toast(error.message || "Could not update policies", "warning");
}
});
apiKeyForm.addEventListener("submit", async (event) => {
event.preventDefault();
const user = selectedUser();
if (!user) return;
try {
const result = await api("/admin/users/api-keys/create", "POST", {
user_id: user.id,
name: fields.keys.name.value.trim() || "default",
});
apiKeyReveal.hidden = false;
apiKeyValue.value = result.api_key || "";
await fetchUsers();
setTab("keys");
toast("API key generated");
} catch (error) {
toast(error.message || "Could not generate API key", "warning");
}
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
button.addEventListener("click", async () => {
menuController.close();
runCommand(button.dataset.command);
try {
await runCommand(button.dataset.command);
} catch (error) {
toast(error.message || "Action failed", "warning");
}
});
});
document.addEventListener("keydown", (event) => {
document.addEventListener("keydown", async (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");
await runCommand("refresh");
}
});
renderStats();
render();
fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning"));
})();

View File

@@ -1,7 +1,15 @@
function authHeaders() {
const headers = {};
const apiKeyEnabled = Boolean(el.apiKeyMode?.checked);
const apiKey = String(el.apiKeyInput?.value || "").trim();
if (apiKeyEnabled && apiKey) headers.Authorization = `Bearer ${apiKey}`;
return headers;
}
async function createBox() {
const response = await fetch("/box", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({
retention_key: el.expiry?.value || defaultRetention,
password: el.password?.value || "",
@@ -28,7 +36,7 @@ async function markFileStatus(item, status) {
try {
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ status }),
});
} catch (_) {
@@ -62,6 +70,8 @@ function uploadFile(item, onComplete) {
formData.append("file", item.file, item.displayName);
xhr.open("POST", item.boxFile.upload_path);
const headers = authHeaders();
if (headers.Authorization) xhr.setRequestHeader("Authorization", headers.Authorization);
xhr.upload.addEventListener("loadstart", () => {
item.loaded = 0;

View File

@@ -34,8 +34,10 @@ function setBoxOptionsLocked(locked) {
function updateDisabledReasons() {
if (el.startButton) {
let reason = "";
const policyMessage = apiKeyPolicyMessage();
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
else if (policyMessage) reason = policyMessage;
else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files.";
else if (!files.length) reason = "There are no files selected. Please select files to upload.";
el.startButton.disabled = false;
@@ -101,6 +103,13 @@ function syncMenuChecks() {
function syncApiKeyField() {
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
if (!el.apiKeyMode?.checked) {
clearTimeout(apiKeyTimer);
apiKeyValidationRun += 1;
resetAccountLimits();
updateLimitHint();
renderFiles();
}
if (el.apiKeyInput) {
el.apiKeyInput.disabled = !enabled;
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
@@ -115,30 +124,83 @@ function validateApiKeyField() {
wrapper?.classList.remove("is-checking");
if (!el.apiKeyMode?.checked) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = "";
updateLimitHint();
renderFiles();
return;
}
const value = el.apiKeyInput.value.trim();
if (!value) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = "waiting";
updateLimitHint();
renderFiles();
saveSettings();
return;
}
if (!validApiKey(value)) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
updateLimitHint();
renderFiles();
saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
return;
}
const runID = apiKeyValidationRun + 1;
apiKeyValidationRun = runID;
el.apiKeyInput.disabled = true;
wrapper?.classList.add("is-checking");
el.apiKeyState.textContent = "checking";
apiKeyTimer = setTimeout(() => {
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
if (validApiKey(value)) {
el.apiKeyState.textContent = "saved locally";
apiKeyTimer = setTimeout(async () => {
try {
const response = await fetch("/auth/me", {
headers: { Authorization: `Bearer ${value}` },
});
let payload = {};
try {
payload = await response.json();
} catch (_) {}
if (runID !== apiKeyValidationRun) return;
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
if (!response.ok || !payload.user) {
resetAccountLimits();
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
updateLimitHint();
renderFiles();
saveSettings();
showToast(payload.error || "API key was not accepted.", "warning");
return;
}
applyAccountLimits(payload.user);
const policyMessage = apiKeyPolicyMessage();
const fileText = maxFileBytes ? formatBytes(maxFileBytes) : "unlimited";
const boxText = maxBoxBytes ? formatBytes(maxBoxBytes) : "unlimited";
el.apiKeyState.textContent = policyMessage ? "limited by policy" : "account limits applied";
updateLimitHint();
renderFiles();
saveSettings();
} else {
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
setStatus(`${payload.user.username || payload.user.email} limits: file ${fileText}, box ${boxText}`);
if (policyMessage) showToast(policyMessage, "warning");
} catch (_) {
if (runID !== apiKeyValidationRun) return;
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
resetAccountLimits();
updateLimitHint();
renderFiles();
el.apiKeyState.textContent = "check failed";
showToast("Could not check API key limits.", "warning");
}
}, 650);
}

View File

@@ -44,16 +44,20 @@ const el = {
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const baseMaxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const baseMaxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const oneTimeRetentionKey = "one-time";
let maxFileBytes = baseMaxFileBytes;
let maxBoxBytes = baseMaxBoxBytes;
let files = [];
let shareUrl = "";
let uploadLocked = false;
let statusTimer = null;
let pendingDuplicateFiles = [];
let apiKeyTimer = null;
let apiKeyValidationRun = 0;
let authenticatedUser = null;
let completedImpactKeys = new Set();
let overallImpactDone = false;
@@ -105,6 +109,33 @@ function hasQuotaError() {
return isOverBoxQuota() || oversizedFiles().length > 0;
}
function effectiveLimit(baseLimit, userLimit) {
return numberFromDataset(userLimit);
}
function resetAccountLimits() {
authenticatedUser = null;
maxFileBytes = baseMaxFileBytes;
maxBoxBytes = baseMaxBoxBytes;
}
function applyAccountLimits(user) {
authenticatedUser = user || null;
const limits = authenticatedUser?.limits || {};
maxFileBytes = effectiveLimit(baseMaxFileBytes, limits.max_file_size_bytes);
maxBoxBytes = effectiveLimit(baseMaxBoxBytes, limits.max_box_size_bytes);
}
function apiKeyPolicyMessage() {
if (!el.apiKeyMode?.checked || !authenticatedUser) return "";
const permissions = authenticatedUser.permissions || {};
if (authenticatedUser.status && authenticatedUser.status !== "active") return "The API key belongs to a disabled account.";
if (!permissions.can_use_api) return "This account is not allowed to use the API.";
if (!permissions.can_create_box) return "This account is not allowed to create boxes.";
if (!permissions.can_upload_file) return "This account is not allowed to upload files.";
return "";
}
function normalizedFileName(name) {
return String(name || "").trim().toLowerCase();
}

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 }}

View File

@@ -61,22 +61,22 @@
<section class="alerts-summary-grid" aria-label="Alerts summary">
<article class="alerts-stat-card is-danger">
<p class="alerts-stat-label">Open alerts</p>
<p class="alerts-stat-value" data-open-count>5</p>
<p class="alerts-stat-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>2</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>3</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>2</p>
<p class="alerts-stat-value" data-closed-count>{{ .ClosedCount }}</p>
<p class="alerts-stat-note">History stays lightweight</p>
</article>
</section>
@@ -134,108 +134,7 @@
<th class="alerts-col-actions">Actions</th>
</tr>
</thead>
<tbody id="alerts-body">
<tr data-id="10" data-severity="high" data-status="open" data-group="storage" data-title="Storage connector unavailable" data-description="Primary local storage connector failed health check and new writes are paused." data-code="301" data-trace="storage.connector.health_failed" data-time="today 14:08" data-metadata='{"connector":"local-main","mode":"read_only","retry_in":"30s"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Storage connector unavailable</td>
<td><span class="alerts-pill high">high</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>301</td>
<td>storage.connector.health_failed</td>
<td>today 14:08</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="9" data-severity="medium" data-status="open" data-group="thumbnails" data-title="Thumbnail generation failed" data-description="Thumbnail generation failed for one uploaded image. Original file remains available." data-code="601" data-trace="thumbnail.generate.failed" data-time="today 13:40" data-metadata='{"box":"bx_49aa","file":"poster.png","worker":"thumb-2"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Thumbnail generation failed</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>601</td>
<td>thumbnail.generate.failed</td>
<td>today 13:40</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="8" data-severity="low" data-status="acked" data-group="uploads" data-title="Large upload nearing account cap" data-description="A user is close to their daily upload budget." data-code="124" data-trace="upload.quota.nearing_cap" data-time="today 12:58" data-metadata='{"user":"geo","used":"44 GB","limit":"50 GB"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Large upload nearing account cap</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>124</td>
<td>upload.quota.nearing_cap</td>
<td>today 12:58</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="7" data-severity="high" data-status="open" data-group="auth" data-title="Repeated admin login failures" data-description="Multiple failed admin login attempts were detected from the same source." data-code="211" data-trace="auth.admin.failed_login_burst" data-time="today 12:10" data-metadata='{"ip":"198.51.100.4","attempts":7,"window":"10m"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Repeated admin login failures</td>
<td><span class="alerts-pill high">high</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>211</td>
<td>auth.admin.failed_login_burst</td>
<td>today 12:10</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="6" data-severity="medium" data-status="acked" data-group="storage" data-title="Cleanup skipped locked files" data-description="Cleanup job encountered locked files and skipped them." data-code="342" data-trace="cleanup.skip.locked_files" data-time="today 10:22" data-metadata='{"count":3,"connector":"local-main"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Cleanup skipped locked files</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>342</td>
<td>cleanup.skip.locked_files</td>
<td>today 10:22</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="5" data-severity="low" data-status="closed" data-group="uploads" data-title="Archive completed with warnings" data-description="ZIP archive completed but excluded one unreadable temporary file." data-code="145" data-trace="archive.complete.with_warning" data-time="today 09:02" data-metadata='{"box":"bx_3901","skipped":1}'>
<td><input type="checkbox" class="row-check"></td>
<td>Archive completed with warnings</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>145</td>
<td>archive.complete.with_warning</td>
<td>today 09:02</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="4" data-severity="medium" data-status="open" data-group="uploads" data-title="Upload session expired mid-transfer" data-description="A long-running upload lost session validity before final commit." data-code="156" data-trace="upload.session.expired_mid_transfer" data-time="yesterday" data-metadata='{"user":"teo","partial_bytes":"1.2 GB"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Upload session expired mid-transfer</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill open">open</span></td>
<td>156</td>
<td>upload.session.expired_mid_transfer</td>
<td>yesterday</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="3" data-severity="low" data-status="closed" data-group="thumbnails" data-title="Thumbnail worker restarted" data-description="Thumbnail worker restarted after a normal watchdog recycle." data-code="602" data-trace="thumbnail.worker.restarted" data-time="yesterday" data-metadata='{"worker":"thumb-1","reason":"watchdog"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Thumbnail worker restarted</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>602</td>
<td>thumbnail.worker.restarted</td>
<td>yesterday</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="2" data-severity="medium" data-status="acked" data-group="auth" data-title="User invited without email delivery confirmation" data-description="Invite creation succeeded but email delivery confirmation was not returned." data-code="224" data-trace="auth.invite.delivery_unknown" data-time="2 days ago" data-metadata='{"user":"reo","provider":"smtp-primary"}'>
<td><input type="checkbox" class="row-check"></td>
<td>User invited without email delivery confirmation</td>
<td><span class="alerts-pill medium">medium</span></td>
<td><span class="alerts-pill acked">acked</span></td>
<td>224</td>
<td>auth.invite.delivery_unknown</td>
<td>2 days ago</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
<tr data-id="1" data-severity="low" data-status="closed" data-group="storage" data-title="Secondary connector caught up" data-description="Delayed sync on a secondary storage connector completed successfully." data-code="329" data-trace="storage.secondary.sync_recovered" data-time="2 days ago" data-metadata='{"connector":"bucket-archive","lag":"0"}'>
<td><input type="checkbox" class="row-check"></td>
<td>Secondary connector caught up</td>
<td><span class="alerts-pill low">low</span></td>
<td><span class="alerts-pill closed">closed</span></td>
<td>329</td>
<td>storage.secondary.sync_recovered</td>
<td>2 days ago</td>
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
</tr>
</tbody>
<tbody id="alerts-body"></tbody>
</table>
</div>
</div>
@@ -287,10 +186,11 @@
<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">
CURRENTLY_MOCKED_LEAVE_AS_IS: alerts use a lightweight lifecycle for now: open, acknowledged, closed.
Alerts persist until deleted. Acknowledge and close update state; delete removes permanently.
</div>
</div>
</section>
@@ -301,11 +201,12 @@
<div class="alerts-footerbar">
<div class="alerts-footer-left">
<span class="alerts-status-pill" id="selected-count">Selected: 0</span>
<span class="alerts-status-pill">10 mocked alerts</span>
<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>
@@ -314,6 +215,7 @@
<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>

View File

@@ -54,6 +54,7 @@
<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="cleanup-expired"><span>C</span><span>Cleanup expired boxes</span><span></span></button>
<button class="menu-action" type="button" data-command="delete"><span>D</span><span>Delete selected</span><span></span></button>
</div>
</div>
@@ -112,6 +113,7 @@
<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" type="button" data-command="cleanup-expired">Cleanup expired</button>
<button class="win98-button boxes-tool-button is-danger" type="button" data-command="delete">Delete</button>
</div>
</div>
@@ -211,6 +213,9 @@
<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 class="boxes-action-grid">
<button class="win98-button boxes-action-button" type="button" data-command="cleanup-expired">Cleanup expired</button>
</div>
</div>
</div>
</section>

View File

@@ -14,13 +14,12 @@
<link rel="stylesheet" href="/static/css/admin.css">
</head>
<body>
{{ $d := .Dashboard }}
<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">
@@ -33,7 +32,6 @@
</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>
@@ -57,83 +55,74 @@
<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>
<button class="menu-action" type="button" data-command="show-all-boxes"><span>B</span><span>Open boxes page</span><span></span></button>
<button class="menu-action" type="button" data-command="export-boxes"><span>C</span><span>Export visible boxes CSV</span><span></span></button>
<button class="menu-action" type="button" data-command="cleanup-expired"><span>D</span><span>Cleanup expired boxes</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>
<button class="menu-action" type="button" data-command="show-all-alerts"><span>!</span><span>Open alerts page</span><span></span></button>
<button class="menu-action" type="button" data-command="close-low-alerts"><span>L</span><span>Close low alerts</span><span></span></button>
<button class="menu-action" type="button" data-command="export-alerts"><span>J</span><span>Export visible 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-activity"><span>A</span><span>Open activity log</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>
<button class="menu-action" type="button" data-command="about"><span>W</span><span>About this dashboard</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>
<p>Live overview for boxes, alerts, storage, users, and recent account 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 class="hero-status-row"><span>Guest uploads</span><strong class="{{ if eq $d.GuestUploadsLabel "enabled" }}status-ok{{ else }}status-danger{{ end }}">{{ $d.GuestUploadsLabel }}</strong></div>
<div class="hero-status-row"><span>API uploads</span><strong class="{{ if eq $d.APIUploadsLabel "enabled" }}status-ok{{ else }}status-danger{{ end }}">{{ $d.APIUploadsLabel }}</strong></div>
<div class="hero-status-row"><span>ZIP downloads</span><strong class="{{ if eq $d.ZipDownloadsLabel "enabled" }}status-ok{{ else }}status-warn{{ end }}">{{ $d.ZipDownloadsLabel }}</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>
<p class="stat-value">{{ $d.ActiveBoxes }}</p>
<p class="stat-note"><span class="stat-note-pill">+{{ $d.BoxesCreatedToday }} today</span><span class="stat-note-pill">{{ $d.PasswordedBoxes }} 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>
<p class="stat-value">{{ $d.StorageFreeLabel }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ $d.StorageUsedLabel }} used</span><span class="stat-note-pill">{{ $d.StorageCapLabel }} cap</span><span class="stat-note-pill">{{ $d.StorageBackend }} backend</span></p>
<span class="meter-track" aria-hidden="true"><span class="meter-bar" style="--meter: {{ $d.StorageMeter }}"></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>
<p class="stat-value"><span id="alertCountValue">{{ $d.OpenAlerts }}</span></p>
<p class="stat-note" id="alertStatNote"><span class="stat-note-pill">{{ $d.HighAlerts }} high</span><span class="stat-note-pill">{{ $d.MediumAlerts }} medium</span><span class="stat-note-pill">{{ $d.LowAlerts }} 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>
<p class="stat-value">{{ $d.TotalUsers }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ $d.ActiveUsers }} active</span><span class="stat-note-pill">{{ $d.DisabledUsers }} disabled</span><span class="stat-note-pill">{{ $d.APIKeyCount }} API keys</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">
@@ -147,87 +136,29 @@
<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>
{{ if $d.Alerts }}
{{ range $d.Alerts }}
<div class="alert-row" data-alert-id="{{ .ID }}" data-severity="{{ .Severity }}" data-alert-title="{{ .Title }}" data-alert-code="{{ .Code }}" data-alert-meta='{{ toJSON .Meta }}'>
<span class="alert-severity">{{ .Severity }}</span>
<div>
<p class="alert-title">{{ .Title }}</p>
<p class="alert-desc">{{ .Message }}</p>
<p class="alert-trace">{{ .Code }} {{ .Trace }} · {{ .CreatedAtLabel }} UTC · {{ .Status }}</p>
</div>
<div class="alert-actions">
<button class="tiny-button" type="button" data-view-meta>Meta</button>
<button class="tiny-button" type="button" data-close-alert>Close</button>
</div>
</div>
{{ end }}
{{ else }}
<div class="dashboard-empty-state">No open alerts. Nice and boring, which is the good kind of admin dashboard.</div>
{{ end }}
</div>
</div>
</div>
</article>
<!-- Recent Activity -->
<article id="recent-activity" class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
@@ -235,33 +166,31 @@
<h2>Recent Activity</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/admin/dashboard#recent-activity">Show all</a>
<a class="titlebar-link-button" href="/admin/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>
{{ if $d.Events }}
{{ range $d.Events }}
<div class="activity-row">
<span class="activity-time">{{ .CreatedAtLabel }}</span>
<div>
<p class="activity-title">{{ .Message }}</p>
<p class="activity-meta">{{ .Kind }} · {{ .Method }} {{ .Path }} {{ if .IP }}· {{ .IP }}{{ end }}</p>
</div>
<span class="tag {{ .TagClass }}">{{ .TagLabel }}</span>
</div>
{{ end }}
{{ else }}
<div class="dashboard-empty-state">No activity has been recorded yet.</div>
{{ end }}
</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">
@@ -269,29 +198,33 @@
<h2>Recent Boxes</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/admin/dashboard#recent-boxes">Show all</a>
<a class="titlebar-link-button" href="/admin/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>
<thead><tr><th>Box</th><th>Status</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>
{{ if $d.Boxes }}
{{ range $d.Boxes }}
<tr data-box-id="{{ .ID }}">
<td>{{ .ID }}</td>
<td><span class="tag {{ .StatusClass }}">{{ .StatusLabel }}</span></td>
<td>{{ .CompleteFiles }}/{{ .FileCount }}</td>
<td>{{ .TotalSizeLabel }}</td>
<td>{{ .CreatedAtLabel }}</td>
<td>{{ .ExpiresAtLabel }}</td>
<td>
{{ range .Flags }}<span class="tag info">{{ . }}</span>{{ end }}
{{ if not .Flags }}<span class="tag ok">plain</span>{{ end }}
</td>
<td><div class="box-actions"><a class="win98-button box-action-button" href="{{ .OpenURL }}">Open</a><a class="win98-button box-action-button" href="/admin/boxes?q={{ .ID }}">Manage</a></div></td>
</tr>
{{ end }}
{{ else }}
<tr><td colspan="8">No boxes found yet.</td></tr>
{{ end }}
</tbody>
</table>
</div>
@@ -300,20 +233,17 @@
</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>
<span>{{ $d.OpenAlerts }} open alert(s)</span>
<span>Live 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">
@@ -327,9 +257,9 @@
</div>
</aside>
<!-- Toast -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script id="dashboard-data" type="application/json">{{ toJSON $d }}</script>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/dashboard.js"></script>
</body>

View File

@@ -8,11 +8,13 @@
<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>
<a class="admin-alert-chip {{ with .AlertChipClass }}{{ . }}{{ else }}is-info{{ end }}" href="/admin/alerts" id="topAlertChip">{{ with .AlertChipLabel }}{{ . }}{{ else }}Alerts{{ end }}</a>
<span class="admin-session-chip">signed in: {{ .AdminUsername }}</span>
</div>
</header>

View File

@@ -0,0 +1,179 @@
{{ 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="bulk-unban"><span>K</span><span>Bulk unban selected</span><span></span></button>
<button class="menu-action" type="button" data-command="unban-all"><span>A</span><span>Unban all</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>admin actions</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>
<button class="win98-button security-button" type="button" data-command="bulk-unban">Bulk unban selected</button>
<button class="win98-button security-button security-danger" type="button" data-command="unban-all">Unban all</button>
<div class="security-note">Ban duration, whitelist rules and trusted proxies are managed in 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>Active bans</strong><span id="security-bans-count">{{ len .Bans }} active bans</span></div>
<div class="security-panel-body security-ban-grid">
<div>
<div class="security-table-toolbar">
<input id="security-ban-filter" class="security-input" type="text" placeholder="Filter by IP">
<select id="security-ban-sort" class="security-input">
<option value="expiry_asc">Expiry ↑</option>
<option value="expiry_desc">Expiry ↓</option>
<option value="ip_asc">IP A-Z</option>
<option value="ip_desc">IP Z-A</option>
</select>
</div>
<div class="security-table-wrap security-bans-wrap">
<table class="security-table">
<thead>
<tr>
<th><input id="security-select-all" type="checkbox" aria-label="Select all"></th>
<th>IP</th>
<th>Status</th>
<th>Ban expires (UTC)</th>
</tr>
</thead>
<tbody id="security-bans-body"></tbody>
</table>
</div>
</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">GeoIP not enabled yet</span></li>
<li><strong>ASN:</strong> <span id="security-detail-asn">GeoIP not enabled yet</span></li>
<li><strong>Ban until:</strong> <span id="security-detail-until">-</span></li>
<li><strong>Why banned:</strong> <span id="security-detail-why">-</span></li>
<li><button id="security-copy-ip" class="win98-button security-button" type="button">Copy IP</button></li>
<li><button id="security-open-activity" class="win98-button security-button" type="button">Search in activity</button></li>
<li><button id="security-open-alerts" class="win98-button security-button" type="button">Search in alerts</button></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>
<section class="security-panel">
<div class="security-panel-header"><strong>Security Runbook</strong><span>ops quick reference</span></div>
<div class="security-panel-body security-docs">
<h4>Reverse Proxy and Trusted CIDRs</h4>
<p>Set <code>WARPBOX_TRUSTED_PROXY_CIDRS</code> to the CIDRs of your proxy nodes only. Without this, all traffic can appear as the proxy or bridge IP, such as <code>172.30.0.1</code>.</p>
<pre>Caddyfile
:443 {
reverse_proxy warpbox:8080 {
header_up X-Forwarded-For {http.request.remote.host}
header_up X-Real-IP {http.request.remote.host}
}
}</pre>
<h4>Ban / Unban Safety</h4>
<p>Use custom ban durations only for active incidents. Prefer temporary bans. Review the "why banned" detail before unbanning to avoid immediate re-abuse.</p>
<h4>Tuning Guidance</h4>
<p>Low traffic: lower <code>security_*_max_attempts</code>. High traffic: increase windows and attempt thresholds gradually, then monitor alerts/activity for false positives.</p>
<h4>GeoIP Guide (planned)</h4>
<p>For <code>geoip2fast</code>, keep lookups async-safe with a single loaded database, add a short timeout per lookup, cache by IP with TTL, and degrade gracefully to "unknown" on failures. Start with security detail pane only, then aggregate stats later.</p>
</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

@@ -35,99 +35,110 @@
<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>
<button class="menu-action" type="button" data-command="tab-add"><span>N</span><span>New 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>
<button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh users</span><span>F5</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>
<button class="menu-action" type="button" data-command="bulk-enable"><span>E</span><span>Enable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-delete"><span>X</span><span>Delete selected</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-warning"><p>With API keys</p><strong id="stat-keys">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>
<aside class="users-control-panel" aria-label="User actions">
<div class="users-selected-card">
<span>Selected user</span>
<strong id="selected-user-name">None</strong>
<small id="selected-user-meta">Choose a row to edit policies and keys.</small>
</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-side-tabs" role="tablist" aria-label="User panels">
<button class="users-tab is-active" type="button" data-tab="add">Add New</button>
<button class="users-tab" type="button" data-tab="edit">Edit</button>
<button class="users-tab" type="button" data-tab="policies">Policies</button>
<button class="users-tab" type="button" data-tab="keys">API Keys</button>
</div>
<section class="users-tab-panel is-active" data-panel="add">
<div class="users-panel-header compact"><div class="users-panel-title">Add New</div></div>
<form id="add-user-form" class="users-form-grid">
<label class="users-field">Username<input class="users-input" id="add-username" type="text" autocomplete="off"></label>
<label class="users-field">Email<input class="users-input" id="add-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>
<label class="users-field">Status<select class="users-select" id="add-status"><option value="active">active</option><option value="disabled">disabled</option></select></label>
<label class="users-field">Max file MB<input class="users-input" id="add-max-file" type="number" min="0" step="1" value="0"></label>
</div>
<label class="users-check"><input type="checkbox" id="users-send-setup" checked>Send setup instructions</label>
<label class="users-field">Max box MB<input class="users-input" id="add-max-box" type="number" min="0" step="1" value="0"></label>
<label class="users-check"><input type="checkbox" id="add-perm-web" checked>Allow web session login</label>
<label class="users-check"><input type="checkbox" id="add-perm-api" checked>Allow API access</label>
<label class="users-check"><input type="checkbox" id="add-perm-create" checked>Allow box creation</label>
<label class="users-check"><input type="checkbox" id="add-perm-upload" checked>Allow file uploads</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>
<button class="win98-button users-action-button" type="submit">Create User</button>
</div>
</form>
</div>
</section>
</section>
<section class="users-tab-panel" data-panel="edit">
<div class="users-panel-header compact"><div class="users-panel-title">Edit Identity</div></div>
<form id="edit-user-form" class="users-form-grid">
<label class="users-field">Username<input class="users-input" id="edit-username" type="text" autocomplete="off" disabled></label>
<label class="users-field">Email<input class="users-input" id="edit-email" type="email" autocomplete="off" disabled></label>
<label class="users-field">Status<select class="users-select" id="edit-status" disabled><option value="active">active</option><option value="disabled">disabled</option></select></label>
<div class="users-form-actions">
<button class="win98-button users-action-button" type="button" id="delete-user-button" disabled>Delete</button>
<button class="win98-button users-action-button" type="submit" disabled id="save-edit-button">Save</button>
</div>
</form>
</section>
<section class="users-tab-panel" data-panel="policies">
<div class="users-panel-header compact"><div class="users-panel-title">Policies</div></div>
<form id="policies-form" class="users-form-grid">
<label class="users-field">Max file MB<input class="users-input" id="policy-max-file" type="number" min="0" step="1" disabled></label>
<label class="users-field">Max box MB<input class="users-input" id="policy-max-box" type="number" min="0" step="1" disabled></label>
<label class="users-check"><input type="checkbox" id="policy-perm-web" disabled>Allow web session login</label>
<label class="users-check"><input type="checkbox" id="policy-perm-api" disabled>Allow API access</label>
<label class="users-check"><input type="checkbox" id="policy-perm-create" disabled>Allow box creation</label>
<label class="users-check"><input type="checkbox" id="policy-perm-upload" disabled>Allow file uploads</label>
<div class="users-form-actions"><button class="win98-button users-action-button" type="submit" disabled id="save-policies-button">Save Policies</button></div>
</form>
</section>
<section class="users-tab-panel" data-panel="keys">
<div class="users-panel-header compact"><div class="users-panel-title">API Keys</div></div>
<form id="api-key-form" class="users-form-grid">
<label class="users-field">Key name<input class="users-input" id="api-key-name" type="text" value="default" disabled></label>
<button class="win98-button users-action-button" type="submit" disabled id="create-key-button">Generate Key</button>
</form>
<div class="users-key-reveal" id="api-key-reveal" hidden>
<span>New API key</span>
<input class="users-input" id="api-key-value" type="text" readonly>
</div>
<div class="users-key-list" id="api-key-list"></div>
</section>
</aside>
<section class="users-panel">
<div class="users-panel-header">
@@ -136,15 +147,14 @@
<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>
<button class="win98-button users-tool-button" type="button" data-command="bulk-delete">Delete</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-status"><option value="all">all statuses</option><option value="active">active</option><option value="disabled">disabled</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="keysDesc">api keys</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">
@@ -155,9 +165,9 @@
<th>User</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Plan</th>
<th>Boxes</th>
<th>Permissions</th>
<th>Limits</th>
<th>Keys</th>
<th>Last seen</th>
<th class="users-col-actions">Actions</th>
</tr>
@@ -179,15 +189,15 @@
</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 id="users-status-left">Ready.</span>
<span>real user store</span>
<span>admin only</span>
</footer>
</div>
</div>
</div>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/users.js"></script>
</body>

View File

@@ -126,6 +126,7 @@
<div class="win98-statusbar upload-statusbar">
<span id="status-text">{{ if .UploadsEnabled }}Ready · drag files anywhere onto the window{{ else }}Guest uploads are disabled{{ end }}</span>
<span>WarpBox</span>
<span>{{ .AppVersion }}</span>
</div>
</section>
@@ -186,7 +187,7 @@
</label>
<label class="option-check">
<input type="checkbox" id="api-key-mode">
<span>Use API key for larger quota</span>
<span>Use API key account limits</span>
</label>
<label class="option-row api-key-row" id="api-key-row">
<span>API key:</span>