Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7cbba1bf2 | |||
| dc379ea6a6 | |||
| 2c9fc03a61 | |||
| 0bdf11d3a7 | |||
| bcdcce8fbd | |||
| fbeff3f6c0 | |||
| dd8dd7cdc2 | |||
| fc54f7bb86 |
@@ -28,8 +28,9 @@ jobs:
|
|||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-t tea.chunkbyte.com/kato/WarpBox:${{ gitea.ref_name }} \
|
--build-arg APP_VERSION=${{ gitea.ref_name }} \
|
||||||
-t tea.chunkbyte.com/kato/WarpBox:latest \
|
-t tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }} \
|
||||||
|
-t tea.chunkbyte.com/kato/warpbox:latest \
|
||||||
.
|
.
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
@@ -41,5 +42,5 @@ jobs:
|
|||||||
|
|
||||||
- name: Push Docker Image
|
- name: Push Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker push tea.chunkbyte.com/kato/WarpBox:${{ gitea.ref_name }}
|
docker push tea.chunkbyte.com/kato/warpbox:${{ gitea.ref_name }}
|
||||||
docker push tea.chunkbyte.com/kato/WarpBox:latest
|
docker push tea.chunkbyte.com/kato/warpbox:latest
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.23-alpine AS builder
|
||||||
|
|
||||||
|
ARG APP_VERSION=""
|
||||||
|
|
||||||
RUN apk add --no-cache git ca-certificates
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
@@ -21,6 +23,9 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o warpbox ./cmd/
|
|||||||
# Stage 2: Runtime
|
# Stage 2: Runtime
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
ARG APP_VERSION=""
|
||||||
|
ENV APP_VERSION=${APP_VERSION}
|
||||||
|
|
||||||
RUN apk add \
|
RUN apk add \
|
||||||
--no-cache \
|
--no-cache \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
|||||||
4
NOTICE
4
NOTICE
@@ -1,4 +0,0 @@
|
|||||||
WarpBox
|
|
||||||
Copyright (c) 2026 Daniel Legt
|
|
||||||
|
|
||||||
This product includes software developed by Daniel Legt.
|
|
||||||
114
TO-DO.md
Normal file
114
TO-DO.md
Normal 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
|
||||||
|
|
||||||
@@ -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.
|
|
||||||
19
docs/geoip-guide.md
Normal file
19
docs/geoip-guide.md
Normal 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
40
docs/security-runbook.md
Normal 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.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WARPBOX_TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy example:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
:443 {
|
||||||
|
reverse_proxy 127.0.0.1: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`.
|
||||||
|
|
||||||
|
## 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
26
go.mod
@@ -3,43 +3,51 @@ module warpbox
|
|||||||
go 1.23.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/dgraph-io/badger/v4 v4.9.1
|
||||||
github.com/gin-contrib/gzip v1.0.1
|
github.com/gin-contrib/gzip v1.0.1
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/pflag v1.0.6
|
github.com/spf13/pflag v1.0.6
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.41.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/google/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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
|
||||||
github.com/stretchr/testify v1.11.1 // indirect
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/net v0.41.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/sys v0.34.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.7 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
53
go.sum
53
go.sum
@@ -2,15 +2,24 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
|
|||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgraph-io/badger/v4 v4.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 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
|
github.com/gin-contrib/gzip v1.0.1 h1:HQ8ENHODeLY7a4g1Au/46Z92bdGFl74OhxcZble9WJE=
|
||||||
@@ -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-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -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/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
@@ -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/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
@@ -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/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
@@ -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/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
116
lib/activity/activity.go
Normal file
116
lib/activity/activity.go
Normal 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
151
lib/alerts/alerts.go
Normal 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
60
lib/boxstore/cleanup.go
Normal 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
|
||||||
|
}
|
||||||
58
lib/boxstore/cleanup_test.go
Normal file
58
lib/boxstore/cleanup_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,15 @@ func TestDefaults(t *testing.T) {
|
|||||||
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
|
if !cfg.GuestUploadsEnabled || !cfg.APIEnabled || !cfg.ZipDownloadsEnabled || !cfg.OneTimeDownloadsEnabled {
|
||||||
t.Fatal("expected default guest/API/download toggles to be enabled")
|
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" {
|
if cfg.AdminUsername != "admin" {
|
||||||
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
t.Fatalf("unexpected admin username: %s", cfg.AdminUsername)
|
||||||
}
|
}
|
||||||
|
if cfg.Environment != AppEnvironmentDevelopment {
|
||||||
|
t.Fatalf("unexpected default environment: %s", cfg.Environment)
|
||||||
|
}
|
||||||
if cfg.AdminPassword != "" {
|
if cfg.AdminPassword != "" {
|
||||||
t.Fatal("expected default admin password to be empty")
|
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_BOX_POLL_INTERVAL_MS", "2000")
|
||||||
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
|
||||||
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
|
||||||
|
t.Setenv("WARPBOX_SECURITY_ENABLED", "false")
|
||||||
|
t.Setenv("WARPBOX_ENV", "production")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,9 +71,15 @@ func TestEnvironmentOverrides(t *testing.T) {
|
|||||||
if !cfg.OneTimeDownloadRetryOnFailure {
|
if !cfg.OneTimeDownloadRetryOnFailure {
|
||||||
t.Fatal("expected one-time retry-on-failure env override to be applied")
|
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 {
|
if cfg.Source(SettingAPIEnabled) != SourceEnv {
|
||||||
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
|
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) {
|
func TestMegabyteSizeEnvironmentOverrides(t *testing.T) {
|
||||||
@@ -113,6 +127,12 @@ func TestInvalidEnvironmentValues(t *testing.T) {
|
|||||||
if _, err := Load(); err == nil {
|
if _, err := Load(); err == nil {
|
||||||
t.Fatal("expected invalid boolean to fail")
|
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) {
|
func TestSettingsOverridePrecedence(t *testing.T) {
|
||||||
@@ -163,6 +183,7 @@ func clearConfigEnv(t *testing.T) {
|
|||||||
"WARPBOX_ADMIN_PASSWORD",
|
"WARPBOX_ADMIN_PASSWORD",
|
||||||
"WARPBOX_ADMIN_USERNAME",
|
"WARPBOX_ADMIN_USERNAME",
|
||||||
"WARPBOX_ADMIN_EMAIL",
|
"WARPBOX_ADMIN_EMAIL",
|
||||||
|
"WARPBOX_ENV",
|
||||||
"WARPBOX_ADMIN_ENABLED",
|
"WARPBOX_ADMIN_ENABLED",
|
||||||
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||||
"WARPBOX_ADMIN_COOKIE_SECURE",
|
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||||
@@ -191,6 +212,8 @@ func clearConfigEnv(t *testing.T) {
|
|||||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||||
|
"WARPBOX_SECURITY_ENABLED",
|
||||||
|
"WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS",
|
||||||
} {
|
} {
|
||||||
t.Setenv(name, "")
|
t.Setenv(name, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
var Definitions = []SettingDefinition{
|
var Definitions = []SettingDefinition{
|
||||||
{Key: SettingDataDir, EnvName: "WARPBOX_DATA_DIR", Label: "Data directory", Type: SettingTypeText, Editable: false, HardLimit: true},
|
{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: 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: SettingAPIEnabled, EnvName: "WARPBOX_API_ENABLED", Label: "API enabled", Type: SettingTypeBool, Editable: true},
|
||||||
{Key: SettingZipDownloadsEnabled, EnvName: "WARPBOX_ZIP_DOWNLOADS_ENABLED", Label: "ZIP downloads enabled", Type: SettingTypeBool, Editable: true},
|
{Key: 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: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
|
||||||
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
|
||||||
|
{Key: SettingActivityRetentionSeconds, EnvName: "WARPBOX_ACTIVITY_RETENTION_SECONDS", Label: "Activity retention seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60},
|
||||||
|
{Key: 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 {
|
func (cfg *Config) SettingRows() []SettingRow {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
DataDir: "./data",
|
DataDir: "./data",
|
||||||
|
Environment: AppEnvironmentDevelopment,
|
||||||
AdminUsername: "admin",
|
AdminUsername: "admin",
|
||||||
AdminEnabled: AdminEnabledAuto,
|
AdminEnabled: AdminEnabledAuto,
|
||||||
AllowAdminSettingsOverride: true,
|
AllowAdminSettingsOverride: true,
|
||||||
@@ -26,6 +27,17 @@ func Load() (*Config, error) {
|
|||||||
BoxPollIntervalMS: 5000,
|
BoxPollIntervalMS: 5000,
|
||||||
ThumbnailBatchSize: 10,
|
ThumbnailBatchSize: 10,
|
||||||
ThumbnailIntervalSeconds: 30,
|
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),
|
sources: make(map[string]Source),
|
||||||
values: make(map[string]string),
|
values: make(map[string]string),
|
||||||
defaults: 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 {
|
if err := cfg.applyStringEnv(SettingDataDir, "WARPBOX_DATA_DIR", &cfg.DataDir); err != nil {
|
||||||
return nil, err
|
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 {
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_PASSWORD", &cfg.AdminPassword); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -47,6 +67,15 @@ func Load() (*Config, error) {
|
|||||||
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
if err := cfg.applyStringEnv("", "WARPBOX_ADMIN_EMAIL", &cfg.AdminEmail); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := cfg.applyStringEnv(SettingSecurityIPWhitelist, "WARPBOX_SECURITY_IP_WHITELIST", &cfg.SecurityIPWhitelist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cfg.applyStringEnv(SettingSecurityAdminIPWhitelist, "WARPBOX_SECURITY_ADMIN_IP_WHITELIST", &cfg.SecurityAdminIPWhitelist); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if 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 != "" {
|
if raw := strings.TrimSpace(os.Getenv("WARPBOX_ADMIN_ENABLED")); raw != "" {
|
||||||
mode := AdminEnabledMode(strings.ToLower(raw))
|
mode := AdminEnabledMode(strings.ToLower(raw))
|
||||||
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
if mode != AdminEnabledAuto && mode != AdminEnabledTrue && mode != AdminEnabledFalse {
|
||||||
@@ -73,6 +102,7 @@ func Load() (*Config, error) {
|
|||||||
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
|
||||||
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
|
||||||
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
|
||||||
|
{SettingSecurityEnabled, "WARPBOX_SECURITY_ENABLED", &cfg.SecurityEnabled},
|
||||||
}
|
}
|
||||||
for _, item := range envBools {
|
for _, item := range envBools {
|
||||||
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
|
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},
|
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
|
||||||
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
|
||||||
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
|
||||||
|
{SettingActivityRetentionSeconds, "WARPBOX_ACTIVITY_RETENTION_SECONDS", 60, &cfg.ActivityRetentionSeconds},
|
||||||
|
{SettingSecurityLoginWindowSecs, "WARPBOX_SECURITY_LOGIN_WINDOW_SECONDS", 10, &cfg.SecurityLoginWindowSeconds},
|
||||||
|
{SettingSecurityBanSeconds, "WARPBOX_SECURITY_BAN_SECONDS", 10, &cfg.SecurityBanSeconds},
|
||||||
|
{SettingSecurityScanWindowSecs, "WARPBOX_SECURITY_SCAN_WINDOW_SECONDS", 10, &cfg.SecurityScanWindowSeconds},
|
||||||
|
{SettingSecurityUploadWindowSecs, "WARPBOX_SECURITY_UPLOAD_WINDOW_SECONDS", 10, &cfg.SecurityUploadWindowSeconds},
|
||||||
|
{SettingExpiredCleanupIntervalSecs, "WARPBOX_EXPIRED_CLEANUP_INTERVAL_SECONDS", 0, &cfg.ExpiredCleanupIntervalSeconds},
|
||||||
}
|
}
|
||||||
for _, item := range envInt64s {
|
for _, item := range envInt64s {
|
||||||
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
|
||||||
@@ -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},
|
{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},
|
{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},
|
{SettingDefaultUserMaxBoxBytes, "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_GB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB", "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", &cfg.DefaultUserMaxBoxSizeBytes},
|
||||||
|
{SettingSecurityUploadMaxGB, "WARPBOX_SECURITY_UPLOAD_MAX_GB", "WARPBOX_SECURITY_UPLOAD_MAX_MB", "WARPBOX_SECURITY_UPLOAD_MAX_BYTES", &cfg.SecurityUploadMaxBytes},
|
||||||
}
|
}
|
||||||
for _, item := range sizeEnvVars {
|
for _, item := range sizeEnvVars {
|
||||||
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
|
if err := cfg.applySizeEnv(item.key, item.gbName, item.mbName, item.bytesName, 0, item.target); err != nil {
|
||||||
@@ -123,6 +160,9 @@ func Load() (*Config, error) {
|
|||||||
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
|
||||||
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
|
||||||
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
|
||||||
|
{SettingSecurityLoginMaxAttempts, "WARPBOX_SECURITY_LOGIN_MAX_ATTEMPTS", 1, &cfg.SecurityLoginMaxAttempts},
|
||||||
|
{SettingSecurityScanMaxAttempts, "WARPBOX_SECURITY_SCAN_MAX_ATTEMPTS", 1, &cfg.SecurityScanMaxAttempts},
|
||||||
|
{SettingSecurityUploadMaxRequests, "WARPBOX_SECURITY_UPLOAD_MAX_REQUESTS", 1, &cfg.SecurityUploadMaxRequests},
|
||||||
}
|
}
|
||||||
for _, item := range envInts {
|
for _, item := range envInts {
|
||||||
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
|
||||||
@@ -138,6 +178,15 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
return nil, fmt.Errorf("WARPBOX_ADMIN_USERNAME cannot be empty")
|
||||||
}
|
}
|
||||||
cfg.AdminEmail = strings.TrimSpace(cfg.AdminEmail)
|
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.UploadsDir = filepath.Join(cfg.DataDir, "uploads")
|
||||||
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
cfg.DBDir = filepath.Join(cfg.DataDir, "db")
|
||||||
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
cfg.setValue(SettingDataDir, cfg.DataDir, cfg.sourceFor(SettingDataDir))
|
||||||
@@ -154,6 +203,7 @@ func (cfg *Config) EnsureDirectories() error {
|
|||||||
}
|
}
|
||||||
func (cfg *Config) captureDefaults() {
|
func (cfg *Config) captureDefaults() {
|
||||||
cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
|
cfg.captureDefaultValue(SettingDataDir, cfg.DataDir)
|
||||||
|
cfg.captureDefaultValue(SettingEnvironment, string(cfg.Environment))
|
||||||
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
|
cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled))
|
||||||
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
|
cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled))
|
||||||
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
|
cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled))
|
||||||
@@ -172,6 +222,20 @@ func (cfg *Config) captureDefaults() {
|
|||||||
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS))
|
||||||
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize))
|
||||||
cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds))
|
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) {
|
func (cfg *Config) captureDefaultValue(key string, value string) {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const (
|
|||||||
AdminEnabledFalse AdminEnabledMode = "false"
|
AdminEnabledFalse AdminEnabledMode = "false"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AppEnvironment string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AppEnvironmentDevelopment AppEnvironment = "development"
|
||||||
|
AppEnvironmentProduction AppEnvironment = "production"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SettingGuestUploadsEnabled = "guest_uploads_enabled"
|
SettingGuestUploadsEnabled = "guest_uploads_enabled"
|
||||||
SettingAPIEnabled = "api_enabled"
|
SettingAPIEnabled = "api_enabled"
|
||||||
@@ -36,6 +43,21 @@ const (
|
|||||||
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
SettingThumbnailBatchSize = "thumbnail_batch_size"
|
||||||
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
|
||||||
SettingDataDir = "data_dir"
|
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
|
type SettingType string
|
||||||
@@ -72,6 +94,7 @@ type Config struct {
|
|||||||
AdminPassword string
|
AdminPassword string
|
||||||
AdminUsername string
|
AdminUsername string
|
||||||
AdminEmail string
|
AdminEmail string
|
||||||
|
Environment AppEnvironment
|
||||||
AdminEnabled AdminEnabledMode
|
AdminEnabled AdminEnabledMode
|
||||||
AdminCookieSecure bool
|
AdminCookieSecure bool
|
||||||
AllowAdminSettingsOverride bool
|
AllowAdminSettingsOverride bool
|
||||||
@@ -95,6 +118,20 @@ type Config struct {
|
|||||||
BoxPollIntervalMS int
|
BoxPollIntervalMS int
|
||||||
ThumbnailBatchSize int
|
ThumbnailBatchSize int
|
||||||
ThumbnailIntervalSeconds 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
|
sources map[string]Source
|
||||||
values map[string]string
|
values map[string]string
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"warpbox/lib/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cfg *Config) ApplyOverrides(overrides map[string]string) error {
|
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)
|
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 {
|
switch def.Type {
|
||||||
case SettingTypeBool:
|
case SettingTypeBool:
|
||||||
parsed, err := parseBool(value)
|
parsed, err := parseBool(value)
|
||||||
@@ -51,11 +59,28 @@ func (cfg *Config) ApplyOverride(key string, value string) error {
|
|||||||
return fmt.Errorf("%s: %w", key, err)
|
return fmt.Errorf("%s: %w", key, err)
|
||||||
}
|
}
|
||||||
cfg.assignInt(key, int(parsed64), SourceDB)
|
cfg.assignInt(key, int(parsed64), SourceDB)
|
||||||
|
case SettingTypeText:
|
||||||
|
cfg.assignText(key, value, SourceDB)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("setting %q is not runtime editable", key)
|
return fmt.Errorf("setting %q is not runtime editable", key)
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func (cfg *Config) assignBool(key string, value bool, source Source) {
|
||||||
switch key {
|
switch key {
|
||||||
case SettingGuestUploadsEnabled:
|
case SettingGuestUploadsEnabled:
|
||||||
@@ -70,6 +95,8 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
|
|||||||
cfg.RenewOnAccessEnabled = value
|
cfg.RenewOnAccessEnabled = value
|
||||||
case SettingRenewOnDownloadEnabled:
|
case SettingRenewOnDownloadEnabled:
|
||||||
cfg.RenewOnDownloadEnabled = value
|
cfg.RenewOnDownloadEnabled = value
|
||||||
|
case SettingSecurityEnabled:
|
||||||
|
cfg.SecurityEnabled = value
|
||||||
}
|
}
|
||||||
cfg.setValue(key, formatBool(value), source)
|
cfg.setValue(key, formatBool(value), source)
|
||||||
}
|
}
|
||||||
@@ -92,8 +119,22 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
|
|||||||
cfg.DefaultUserMaxBoxSizeBytes = value
|
cfg.DefaultUserMaxBoxSizeBytes = value
|
||||||
case SettingSessionTTLSeconds:
|
case SettingSessionTTLSeconds:
|
||||||
cfg.SessionTTLSeconds = value
|
cfg.SessionTTLSeconds = value
|
||||||
|
case SettingActivityRetentionSeconds:
|
||||||
|
cfg.ActivityRetentionSeconds = value
|
||||||
|
case SettingSecurityLoginWindowSecs:
|
||||||
|
cfg.SecurityLoginWindowSeconds = value
|
||||||
|
case SettingSecurityBanSeconds:
|
||||||
|
cfg.SecurityBanSeconds = value
|
||||||
|
case SettingSecurityScanWindowSecs:
|
||||||
|
cfg.SecurityScanWindowSeconds = value
|
||||||
|
case SettingSecurityUploadWindowSecs:
|
||||||
|
cfg.SecurityUploadWindowSeconds = value
|
||||||
|
case SettingSecurityUploadMaxGB:
|
||||||
|
cfg.SecurityUploadMaxBytes = value
|
||||||
|
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)
|
cfg.setValue(key, formatGigabytesFromBytes(value), source)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,10 +149,28 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
|
|||||||
cfg.ThumbnailBatchSize = value
|
cfg.ThumbnailBatchSize = value
|
||||||
case SettingThumbnailIntervalSeconds:
|
case SettingThumbnailIntervalSeconds:
|
||||||
cfg.ThumbnailIntervalSeconds = value
|
cfg.ThumbnailIntervalSeconds = value
|
||||||
|
case SettingSecurityLoginMaxAttempts:
|
||||||
|
cfg.SecurityLoginMaxAttempts = value
|
||||||
|
case SettingSecurityScanMaxAttempts:
|
||||||
|
cfg.SecurityScanMaxAttempts = value
|
||||||
|
case SettingSecurityUploadMaxRequests:
|
||||||
|
cfg.SecurityUploadMaxRequests = value
|
||||||
}
|
}
|
||||||
cfg.setValue(key, strconv.Itoa(value), source)
|
cfg.setValue(key, strconv.Itoa(value), source)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) assignText(key string, value string, source Source) {
|
||||||
|
switch key {
|
||||||
|
case SettingSecurityIPWhitelist:
|
||||||
|
cfg.SecurityIPWhitelist = value
|
||||||
|
case SettingSecurityAdminIPWhitelist:
|
||||||
|
cfg.SecurityAdminIPWhitelist = value
|
||||||
|
case SettingTrustedProxyCIDRs:
|
||||||
|
cfg.TrustedProxyCIDRs = value
|
||||||
|
}
|
||||||
|
cfg.setValue(key, value, source)
|
||||||
|
}
|
||||||
|
|
||||||
func (cfg *Config) setValue(key string, value string, source Source) {
|
func (cfg *Config) setValue(key string, value string, source Source) {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -26,12 +26,25 @@ type Handlers struct {
|
|||||||
AdminBoxes gin.HandlerFunc
|
AdminBoxes gin.HandlerFunc
|
||||||
AdminBoxesAction gin.HandlerFunc
|
AdminBoxesAction gin.HandlerFunc
|
||||||
AdminUsers 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
|
AdminSettings gin.HandlerFunc
|
||||||
AdminSettingsExport gin.HandlerFunc
|
AdminSettingsExport gin.HandlerFunc
|
||||||
AdminSettingsSave gin.HandlerFunc
|
AdminSettingsSave gin.HandlerFunc
|
||||||
AdminSettingsImport gin.HandlerFunc
|
AdminSettingsImport gin.HandlerFunc
|
||||||
AdminSettingsReset gin.HandlerFunc
|
AdminSettingsReset gin.HandlerFunc
|
||||||
AdminAuth gin.HandlerFunc
|
AdminAuth gin.HandlerFunc
|
||||||
|
UserLogin gin.HandlerFunc
|
||||||
|
UserLogout gin.HandlerFunc
|
||||||
|
UserMe gin.HandlerFunc
|
||||||
|
UserCreateAPIKey gin.HandlerFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func Register(router *gin.Engine, handlers Handlers) {
|
func Register(router *gin.Engine, handlers Handlers) {
|
||||||
@@ -53,6 +66,10 @@ func Register(router *gin.Engine, handlers Handlers) {
|
|||||||
// Legacy upload routes are kept for compatibility with older clients.
|
// Legacy upload routes are kept for compatibility with older clients.
|
||||||
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
|
router.POST("/box/:id/upload", handlers.DirectBoxUpload)
|
||||||
router.POST("/upload", handlers.LegacyUpload)
|
router.POST("/upload", handlers.LegacyUpload)
|
||||||
|
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 := router.Group("/admin")
|
||||||
admin.GET("/login", handlers.AdminLogin)
|
admin.GET("/login", handlers.AdminLogin)
|
||||||
@@ -62,9 +79,18 @@ func Register(router *gin.Engine, handlers Handlers) {
|
|||||||
protected := router.Group("/admin", handlers.AdminAuth)
|
protected := router.Group("/admin", handlers.AdminAuth)
|
||||||
protected.GET("/dashboard", handlers.AdminDashboard)
|
protected.GET("/dashboard", handlers.AdminDashboard)
|
||||||
protected.GET("/alerts", handlers.AdminAlerts)
|
protected.GET("/alerts", handlers.AdminAlerts)
|
||||||
|
protected.POST("/alerts/actions", handlers.AdminAlertsAction)
|
||||||
protected.GET("/boxes", handlers.AdminBoxes)
|
protected.GET("/boxes", handlers.AdminBoxes)
|
||||||
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
|
||||||
protected.GET("/users", handlers.AdminUsers)
|
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", handlers.AdminSettings)
|
||||||
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
protected.GET("/settings/export", handlers.AdminSettingsExport)
|
||||||
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
protected.POST("/settings/save", handlers.AdminSettingsSave)
|
||||||
|
|||||||
426
lib/security/guard.go
Normal file
426
lib/security/guard.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
52
lib/security/guard_test.go
Normal file
52
lib/security/guard_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/alerts"
|
||||||
"warpbox/lib/config"
|
"warpbox/lib/config"
|
||||||
|
"warpbox/lib/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
const adminSessionCookie = "warpbox_admin_session"
|
const adminSessionCookie = "warpbox_admin_session"
|
||||||
@@ -59,17 +62,39 @@ func (app *App) handleAdminLoginPost(ctx *gin.Context) {
|
|||||||
ctx.Redirect(http.StatusSeeOther, "/")
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
return
|
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"))
|
username := strings.TrimSpace(ctx.PostForm("username"))
|
||||||
password := ctx.PostForm("password")
|
password := ctx.PostForm("password")
|
||||||
|
|
||||||
if username != app.config.AdminUsername || password != app.config.AdminPassword {
|
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{
|
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
|
||||||
"ErrorMessage": "Invalid username or password.",
|
"ErrorMessage": "Invalid username or password.",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil)
|
||||||
secure := app.config.AdminCookieSecure
|
secure := app.config.AdminCookieSecure
|
||||||
maxAge := int(app.config.SessionTTLSeconds)
|
maxAge := int(app.config.SessionTTLSeconds)
|
||||||
|
|
||||||
@@ -108,9 +133,41 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alertsList := []alerts.Alert{}
|
||||||
|
if app.alertStore != nil {
|
||||||
|
var err error
|
||||||
|
alertsList, err = app.alertStore.List(500)
|
||||||
|
if err != nil {
|
||||||
|
ctx.String(http.StatusInternalServerError, "Could not load alerts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openCount := 0
|
||||||
|
highCount := 0
|
||||||
|
ackedCount := 0
|
||||||
|
closedCount := 0
|
||||||
|
for _, alert := range alertsList {
|
||||||
|
switch string(alert.Status) {
|
||||||
|
case "open":
|
||||||
|
openCount++
|
||||||
|
case "acked":
|
||||||
|
ackedCount++
|
||||||
|
case "closed":
|
||||||
|
closedCount++
|
||||||
|
}
|
||||||
|
if alert.Severity == "high" && string(alert.Status) != "closed" {
|
||||||
|
highCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
|
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
|
||||||
"AdminUsername": app.config.AdminUsername,
|
"AdminUsername": app.config.AdminUsername,
|
||||||
"AdminEmail": app.config.AdminEmail,
|
"AdminEmail": app.config.AdminEmail,
|
||||||
"ActivePage": "alerts",
|
"ActivePage": "alerts",
|
||||||
|
"Alerts": alertsList,
|
||||||
|
"OpenCount": strconv.Itoa(openCount),
|
||||||
|
"HighCount": strconv.Itoa(highCount),
|
||||||
|
"AckCount": strconv.Itoa(ackedCount),
|
||||||
|
"ClosedCount": strconv.Itoa(closedCount),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,22 +84,41 @@ func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(request.BoxIDs) == 0 {
|
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch request.Action {
|
switch request.Action {
|
||||||
case "delete", "expire", "bump":
|
case "delete", "expire", "bump", "cleanup_expired":
|
||||||
default:
|
default:
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
|
||||||
return
|
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 {
|
if request.Action == "bump" && request.DeltaSeconds <= 0 {
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
|
||||||
return
|
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
|
processed := 0
|
||||||
warnings := make([]string, 0)
|
warnings := make([]string, 0)
|
||||||
@@ -299,6 +318,8 @@ func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) s
|
|||||||
return fmt.Sprintf("Expired %d box(es)", processed)
|
return fmt.Sprintf("Expired %d box(es)", processed)
|
||||||
case "bump":
|
case "bump":
|
||||||
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
|
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:
|
default:
|
||||||
return "Action complete"
|
return "Action complete"
|
||||||
}
|
}
|
||||||
|
|||||||
331
lib/server/admin_security.go
Normal file
331
lib/server/admin_security.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
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 !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)
|
||||||
|
}
|
||||||
125
lib/server/admin_security_test.go
Normal file
125
lib/server/admin_security_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
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 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
|
||||||
|
}
|
||||||
@@ -269,6 +269,9 @@ func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSetti
|
|||||||
|
|
||||||
app.config = nextCfg
|
app.config = nextCfg
|
||||||
applyBoxstoreRuntimeConfig(app.config)
|
applyBoxstoreRuntimeConfig(app.config)
|
||||||
|
if err := app.reloadSecurityConfig(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
rows, _ := app.buildAdminSettingsRows()
|
rows, _ := app.buildAdminSettingsRows()
|
||||||
return rows, warnings, nil
|
return rows, warnings, nil
|
||||||
}
|
}
|
||||||
@@ -399,6 +402,8 @@ func settingsCategoryMeta() []settingsCategoryInfo {
|
|||||||
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
||||||
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
||||||
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
||||||
|
{Key: "security", Label: "Security", Icon: "🔒"},
|
||||||
|
{Key: "activity", Label: "Activity", Icon: "☰"},
|
||||||
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
||||||
{Key: "api", Label: "API", Icon: "{ }"},
|
{Key: "api", Label: "API", Icon: "{ }"},
|
||||||
{Key: "storage", Label: "Storage", Icon: "▥"},
|
{Key: "storage", Label: "Storage", Icon: "▥"},
|
||||||
@@ -428,10 +433,16 @@ func settingsCategoryForKey(key string) string {
|
|||||||
switch key {
|
switch key {
|
||||||
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
||||||
return "uploads"
|
return "uploads"
|
||||||
|
case config.SettingSecurityUploadWindowSecs, config.SettingSecurityUploadMaxRequests, config.SettingSecurityUploadMaxGB:
|
||||||
|
return "uploads"
|
||||||
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||||
return "downloads"
|
return "downloads"
|
||||||
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||||
return "retention"
|
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:
|
case config.SettingSessionTTLSeconds:
|
||||||
return "accounts"
|
return "accounts"
|
||||||
case config.SettingAPIEnabled:
|
case config.SettingAPIEnabled:
|
||||||
@@ -440,6 +451,8 @@ func settingsCategoryForKey(key string) string {
|
|||||||
return "storage"
|
return "storage"
|
||||||
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
|
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
|
||||||
return "workers"
|
return "workers"
|
||||||
|
case config.SettingExpiredCleanupIntervalSecs:
|
||||||
|
return "workers"
|
||||||
default:
|
default:
|
||||||
return "accounts"
|
return "accounts"
|
||||||
}
|
}
|
||||||
@@ -466,6 +479,19 @@ func settingsDescription(key string) string {
|
|||||||
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||||
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
||||||
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
|
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]
|
return descriptions[key]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ func clearAdminSettingsEnv(t *testing.T) {
|
|||||||
"WARPBOX_ADMIN_PASSWORD",
|
"WARPBOX_ADMIN_PASSWORD",
|
||||||
"WARPBOX_ADMIN_USERNAME",
|
"WARPBOX_ADMIN_USERNAME",
|
||||||
"WARPBOX_ADMIN_EMAIL",
|
"WARPBOX_ADMIN_EMAIL",
|
||||||
|
"WARPBOX_ENV",
|
||||||
"WARPBOX_ADMIN_ENABLED",
|
"WARPBOX_ADMIN_ENABLED",
|
||||||
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||||
"WARPBOX_ADMIN_COOKIE_SECURE",
|
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||||
@@ -265,7 +266,36 @@ func clearAdminSettingsEnv(t *testing.T) {
|
|||||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||||
|
"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, "")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,82 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
func (app *App) handleAdminUsers(ctx *gin.Context) {
|
||||||
if !app.adminLoginEnabled() {
|
if !app.adminLoginEnabled() {
|
||||||
ctx.Redirect(http.StatusSeeOther, "/")
|
ctx.Redirect(http.StatusSeeOther, "/")
|
||||||
@@ -18,3 +90,154 @@ func (app *App) handleAdminUsers(ctx *gin.Context) {
|
|||||||
"ActivePage": "users",
|
"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
62
lib/server/cleanup.go
Normal 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")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
107
lib/server/ip.go
Normal file
107
lib/server/ip.go
Normal 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()
|
||||||
|
}
|
||||||
44
lib/server/ip_test.go
Normal file
44
lib/server/ip_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ func (app *App) handleIndex(ctx *gin.Context) {
|
|||||||
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
|
"UploadsEnabled": app.config.GuestUploadsEnabled && app.config.APIEnabled,
|
||||||
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
|
"MaxFileSizeBytes": app.config.GlobalMaxFileSizeBytes,
|
||||||
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
|
"MaxBoxSizeBytes": app.config.GlobalMaxBoxSizeBytes,
|
||||||
|
"AppVersion": app.appVersion,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,31 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"warpbox/lib/activity"
|
||||||
|
"warpbox/lib/alerts"
|
||||||
"warpbox/lib/boxstore"
|
"warpbox/lib/boxstore"
|
||||||
"warpbox/lib/config"
|
"warpbox/lib/config"
|
||||||
"warpbox/lib/routing"
|
"warpbox/lib/routing"
|
||||||
|
"warpbox/lib/security"
|
||||||
|
"warpbox/lib/userstore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
settingsOverridesPath string
|
settingsOverridesPath string
|
||||||
|
activityStore *activity.Store
|
||||||
|
alertStore *alerts.Store
|
||||||
|
securityGuard *security.Guard
|
||||||
|
appVersion string
|
||||||
|
userStore *userstore.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(addr string) error {
|
func Run(addr string) error {
|
||||||
@@ -24,6 +35,12 @@ func Run(addr string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
switch cfg.Environment {
|
||||||
|
case config.AppEnvironmentProduction:
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
default:
|
||||||
|
gin.SetMode(gin.DebugMode)
|
||||||
|
}
|
||||||
if err := cfg.EnsureDirectories(); err != nil {
|
if err := cfg.EnsureDirectories(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -38,9 +55,27 @@ func Run(addr string) error {
|
|||||||
|
|
||||||
applyBoxstoreRuntimeConfig(cfg)
|
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 := gin.Default()
|
||||||
|
router.Use(app.versionHeaderMiddleware())
|
||||||
|
router.Use(app.securityMiddleware())
|
||||||
|
router.NoRoute(app.handleNoRoute)
|
||||||
htmlTemplates, err := loadHTMLTemplates()
|
htmlTemplates, err := loadHTMLTemplates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -71,18 +106,32 @@ func Run(addr string) error {
|
|||||||
AdminBoxes: app.handleAdminBoxes,
|
AdminBoxes: app.handleAdminBoxes,
|
||||||
AdminBoxesAction: app.handleAdminBoxesAction,
|
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||||
AdminUsers: app.handleAdminUsers,
|
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,
|
AdminSettings: app.handleAdminSettings,
|
||||||
AdminSettingsExport: app.handleAdminSettingsExport,
|
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||||
AdminSettingsSave: app.handleAdminSettingsSave,
|
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||||
AdminSettingsImport: app.handleAdminSettingsImport,
|
AdminSettingsImport: app.handleAdminSettingsImport,
|
||||||
AdminSettingsReset: app.handleAdminSettingsReset,
|
AdminSettingsReset: app.handleAdminSettingsReset,
|
||||||
AdminAuth: app.adminAuthMiddleware,
|
AdminAuth: app.adminAuthMiddleware,
|
||||||
|
UserLogin: app.handleUserLogin,
|
||||||
|
UserLogout: app.handleUserLogout,
|
||||||
|
UserMe: app.handleUserMe,
|
||||||
|
UserCreateAPIKey: app.handleSelfCreateAPIKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||||
compressed.Static("/static", "./static")
|
compressed.Static("/static", "./static")
|
||||||
|
|
||||||
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
|
boxstore.StartThumbnailWorker(cfg.ThumbnailBatchSize, time.Duration(cfg.ThumbnailIntervalSeconds)*time.Second)
|
||||||
|
app.startExpiredCleanupWorker()
|
||||||
|
|
||||||
return router.Run(addr)
|
return router.Run(addr)
|
||||||
}
|
}
|
||||||
@@ -121,3 +170,18 @@ func (app *App) handleHealth(c *gin.Context) {
|
|||||||
"status": "healthy",
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
|||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
app.limitRequestBody(ctx)
|
actor, ok := app.authorizeUpload(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBodyForActor(ctx, actor)
|
||||||
|
|
||||||
boxID, err := boxstore.NewBoxID()
|
boxID, err := boxstore.NewBoxID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,10 +39,17 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
|
||||||
return
|
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()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
totalSize := int64(0)
|
||||||
|
for _, file := range request.Files {
|
||||||
|
totalSize += file.Size
|
||||||
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, totalSize) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
files, err := boxstore.CreateManifest(boxID, request)
|
files, err := boxstore.CreateManifest(boxID, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,7 +64,11 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
|
|||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
app.limitRequestBody(ctx)
|
actor, ok := app.authorizeUpload(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBodyForActor(ctx, actor)
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
fileID := ctx.Param("file_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"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||||
return
|
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)
|
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, file.Size) {
|
||||||
|
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
savedFile, err := boxstore.SaveManifestUpload(boxID, fileID, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,7 +143,11 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
|
|||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
app.limitRequestBody(ctx)
|
actor, ok := app.authorizeUpload(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBodyForActor(ctx, actor)
|
||||||
|
|
||||||
boxID := ctx.Param("id")
|
boxID := ctx.Param("id")
|
||||||
if !boxstore.ValidBoxID(boxID) {
|
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"})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
|
||||||
return
|
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()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, file.Size) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
savedFile, err := boxstore.SaveUpload(boxID, file)
|
savedFile, err := boxstore.SaveUpload(boxID, file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -155,7 +181,11 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
|||||||
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
app.limitRequestBody(ctx)
|
actor, ok := app.authorizeUpload(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.limitRequestBodyForActor(ctx, actor)
|
||||||
|
|
||||||
form, err := ctx.MultipartForm()
|
form, err := ctx.MultipartForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -170,16 +200,19 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
totalSize := int64(0)
|
totalSize := int64(0)
|
||||||
for _, file := range files {
|
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()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
totalSize += file.Size
|
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()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !app.enforceUploadRateLimit(ctx, totalSize) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
boxID, err := boxstore.NewBoxID()
|
boxID, err := boxstore.NewBoxID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -209,7 +242,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
|
|||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
|
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()})
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
188
lib/server/user_auth.go
Normal file
188
lib/server/user_auth.go
Normal 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)})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -28,6 +29,10 @@ func (app *App) requireGuestUploads(ctx *gin.Context) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
|
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 {
|
if request == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -44,19 +49,23 @@ func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error
|
|||||||
|
|
||||||
totalSize := int64(0)
|
totalSize := int64(0)
|
||||||
for _, file := range request.Files {
|
for _, file := range request.Files {
|
||||||
if err := app.validateFileSize(file.Size); err != nil {
|
if err := app.validateFileSizeForActor(file.Size, actor); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
totalSize += file.Size
|
totalSize += file.Size
|
||||||
}
|
}
|
||||||
return app.validateBoxSize(totalSize)
|
return app.validateBoxSizeForActor(totalSize, actor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) validateIncomingFile(boxID string, size int64) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
if app.effectiveMaxBoxBytes(actor) <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,23 +77,27 @@ func (app *App) validateIncomingFile(boxID string, size int64) error {
|
|||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
totalSize += file.Size
|
totalSize += file.Size
|
||||||
}
|
}
|
||||||
return app.validateBoxSize(totalSize)
|
return app.validateBoxSizeForActor(totalSize, actor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, err := boxstore.ReadManifest(boxID)
|
manifest, err := boxstore.ReadManifest(boxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return app.validateIncomingFile(boxID, size)
|
return app.validateIncomingFileForActor(boxID, size, actor)
|
||||||
}
|
}
|
||||||
if boxstore.IsExpired(manifest) {
|
if boxstore.IsExpired(manifest) {
|
||||||
_ = boxstore.DeleteBox(boxID)
|
_ = boxstore.DeleteBox(boxID)
|
||||||
return fmt.Errorf("Box expired")
|
return fmt.Errorf("Box expired")
|
||||||
}
|
}
|
||||||
if app.config.GlobalMaxBoxSizeBytes <= 0 {
|
if app.effectiveMaxBoxBytes(actor) <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
totalSize := int64(0)
|
totalSize := int64(0)
|
||||||
@@ -100,24 +113,54 @@ func (app *App) validateManifestFileUpload(boxID string, fileID string, size int
|
|||||||
if !found {
|
if !found {
|
||||||
totalSize += size
|
totalSize += size
|
||||||
}
|
}
|
||||||
return app.validateBoxSize(totalSize)
|
return app.validateBoxSizeForActor(totalSize, actor)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) validateFileSize(size int64) error {
|
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 {
|
if size < 0 {
|
||||||
return fmt.Errorf("File size cannot be negative")
|
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 fmt.Errorf("File exceeds the global max file size")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) validateBoxSize(size int64) error {
|
func (app *App) validateBoxSize(size int64) error {
|
||||||
|
return app.validateBoxSizeForActor(size, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) validateBoxSizeForActor(size int64, actor *requestActor) error {
|
||||||
if size < 0 {
|
if size < 0 {
|
||||||
return fmt.Errorf("Box size cannot be negative")
|
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 fmt.Errorf("Box exceeds the global max box size")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -136,7 +179,11 @@ func (app *App) rejectExpiredManifestBox(boxID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) limitRequestBody(ctx *gin.Context) {
|
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 {
|
if limit <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -144,12 +191,53 @@ func (app *App) limitRequestBody(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) maxRequestBodyBytes() int64 {
|
func (app *App) maxRequestBodyBytes() int64 {
|
||||||
limit := app.config.GlobalMaxBoxSizeBytes
|
return app.maxRequestBodyBytesForActor(nil)
|
||||||
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit {
|
}
|
||||||
limit = app.config.GlobalMaxFileSizeBytes
|
|
||||||
|
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 {
|
if limit <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return limit + 10*1024*1024
|
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
369
lib/userstore/store.go
Normal 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
14
run.sh
@@ -7,6 +7,7 @@ if [ -f .env ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Core service switches.
|
# Core service switches.
|
||||||
|
export WARPBOX_ENV="${WARPBOX_ENV:-development}"
|
||||||
export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}"
|
export WARPBOX_GUEST_UPLOADS_ENABLED="${WARPBOX_GUEST_UPLOADS_ENABLED:-true}"
|
||||||
export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}"
|
export WARPBOX_API_ENABLED="${WARPBOX_API_ENABLED:-true}"
|
||||||
export WARPBOX_ZIP_DOWNLOADS_ENABLED="${WARPBOX_ZIP_DOWNLOADS_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_BOX_POLL_INTERVAL_MS="${WARPBOX_BOX_POLL_INTERVAL_MS:-5000}"
|
||||||
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
|
export WARPBOX_THUMBNAIL_BATCH_SIZE="${WARPBOX_THUMBNAIL_BATCH_SIZE:-10}"
|
||||||
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
|
export WARPBOX_THUMBNAIL_INTERVAL_SECONDS="${WARPBOX_THUMBNAIL_INTERVAL_SECONDS:-30}"
|
||||||
|
export WARPBOX_ACTIVITY_RETENTION_SECONDS="${WARPBOX_ACTIVITY_RETENTION_SECONDS:-604800}"
|
||||||
|
export WARPBOX_SECURITY_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.
|
# Data location.
|
||||||
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
|
export WARPBOX_DATA_DIR="${WARPBOX_DATA_DIR:-./data}"
|
||||||
|
|||||||
63
static/css/activity.css
Normal file
63
static/css/activity.css
Normal 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;
|
||||||
|
}
|
||||||
100
static/css/security.css
Normal file
100
static/css/security.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
.upload-statusbar {
|
.upload-statusbar {
|
||||||
grid-template-columns: 1fr 100px;
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-stack {
|
.side-stack {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.users-page-body {
|
.users-page-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-hero {
|
.users-hero {
|
||||||
@@ -69,11 +70,92 @@
|
|||||||
|
|
||||||
.users-main-grid {
|
.users-main-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 0;
|
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 {
|
.users-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -97,6 +179,11 @@
|
|||||||
border-bottom: 1px solid #b0b0b0;
|
border-bottom: 1px solid #b0b0b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.users-panel-header.compact {
|
||||||
|
margin: -8px -8px 0;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
.users-panel-title {
|
.users-panel-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -194,13 +281,13 @@
|
|||||||
|
|
||||||
.users-toolbar-grid {
|
.users-toolbar-grid {
|
||||||
display: 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;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-table-wrap {
|
.users-table-wrap {
|
||||||
min-height: 420px;
|
min-height: 360px;
|
||||||
height: 420px;
|
height: min(54vh, 520px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-top: 2px solid #606060;
|
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(odd) { background: rgba(255,255,255,.96); }
|
||||||
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
|
||||||
.users-table tbody tr:hover { background: #d8e5f8; }
|
.users-table tbody tr:hover { background: #d8e5f8; }
|
||||||
|
.users-table tbody tr.is-selected { background: #c8d8ff; }
|
||||||
|
|
||||||
.users-col-check { width: 30px; }
|
.users-col-check { width: 30px; }
|
||||||
.users-col-actions { width: 136px; }
|
.users-col-actions { width: 136px; }
|
||||||
@@ -301,6 +389,70 @@
|
|||||||
line-height: 12px;
|
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) {
|
@media (max-width: 1024px) {
|
||||||
.users-main-grid,
|
.users-main-grid,
|
||||||
.users-hero {
|
.users-hero {
|
||||||
|
|||||||
106
static/js/admin/activity.js
Normal file
106
static/js/admin/activity.js
Normal 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();
|
||||||
|
})();
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || {
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||||
close() {
|
const dataNode = document.getElementById("alerts-data");
|
||||||
document.querySelectorAll(".menu-item.is-open").forEach((item) => {
|
const alertsBody = document.getElementById("alerts-body");
|
||||||
item.classList.remove("is-open");
|
|
||||||
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const toast = document.getElementById("toast");
|
|
||||||
const searchInput = document.getElementById("search-input");
|
const searchInput = document.getElementById("search-input");
|
||||||
const severityFilter = document.getElementById("severity-filter");
|
const severityFilter = document.getElementById("severity-filter");
|
||||||
const statusFilter = document.getElementById("status-filter");
|
const statusFilter = document.getElementById("status-filter");
|
||||||
const sourceFilter = document.getElementById("source-filter");
|
const sourceFilter = document.getElementById("source-filter");
|
||||||
const sortFilter = document.getElementById("sort-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 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 = {
|
const detailEls = {
|
||||||
title: document.getElementById("detail-title"),
|
title: document.getElementById("detail-title"),
|
||||||
@@ -32,185 +23,245 @@
|
|||||||
metadata: document.getElementById("detail-metadata")
|
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) {
|
function showToast(message, type = "info", duration = 1800) {
|
||||||
if (window.WarpBoxUI) {
|
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||||
window.WarpBoxUI.toast(message, type, { target: toast, duration });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!toast) return;
|
|
||||||
toast.textContent = message;
|
|
||||||
toast.classList.add("is-visible");
|
|
||||||
window.setTimeout(() => toast.classList.remove("is-visible"), duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function allRows() {
|
function createdLabel(value) {
|
||||||
return Array.from(alertsBody.querySelectorAll("tr"));
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return "-";
|
||||||
|
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleRows() {
|
function allAlerts() {
|
||||||
return allRows().filter((row) => row.style.display !== "none");
|
return state.alerts.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedRows() {
|
function filteredAlerts() {
|
||||||
return allRows().filter((row) => row.querySelector(".row-check")?.checked && row.style.display !== "none");
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelectedCount() {
|
|
||||||
selectedCountEl.textContent = `Selected: ${selectedRows().length}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSummaryCounts() {
|
|
||||||
const rows = visibleRows();
|
|
||||||
openCountEl.textContent = String(rows.filter((row) => row.dataset.status === "open").length);
|
|
||||||
highCountEl.textContent = String(rows.filter((row) => row.dataset.severity === "high" && row.dataset.status !== "closed").length);
|
|
||||||
ackCountEl.textContent = String(rows.filter((row) => row.dataset.status === "acked").length);
|
|
||||||
closedCountEl.textContent = String(rows.filter((row) => row.dataset.status === "closed").length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDetails(row) {
|
|
||||||
if (!row) return;
|
|
||||||
allRows().forEach((item) => item.classList.remove("is-selected"));
|
|
||||||
row.classList.add("is-selected");
|
|
||||||
detailEls.title.textContent = row.dataset.title || "";
|
|
||||||
detailEls.severity.textContent = row.dataset.severity || "";
|
|
||||||
detailEls.status.textContent = row.dataset.status || "";
|
|
||||||
detailEls.code.textContent = row.dataset.code || "";
|
|
||||||
detailEls.trace.textContent = row.dataset.trace || "";
|
|
||||||
detailEls.time.textContent = row.dataset.time || "";
|
|
||||||
detailEls.description.textContent = row.dataset.description || "";
|
|
||||||
try {
|
|
||||||
detailEls.metadata.textContent = JSON.stringify(JSON.parse(row.dataset.metadata || "{}"), null, 2);
|
|
||||||
} catch (_) {
|
|
||||||
detailEls.metadata.textContent = row.dataset.metadata || "{}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
const search = searchInput.value.trim().toLowerCase();
|
|
||||||
const severity = severityFilter.value;
|
const severity = severityFilter.value;
|
||||||
const status = statusFilter.value;
|
const status = statusFilter.value;
|
||||||
const group = sourceFilter.value;
|
const group = sourceFilter.value;
|
||||||
|
const rows = allAlerts().filter((alert) => {
|
||||||
allRows().forEach((row) => {
|
|
||||||
const haystack = [
|
const haystack = [
|
||||||
row.dataset.title,
|
alert.title,
|
||||||
row.dataset.description,
|
alert.message,
|
||||||
row.dataset.code,
|
alert.code,
|
||||||
row.dataset.trace,
|
alert.trace,
|
||||||
row.dataset.group
|
alert.group
|
||||||
].join(" ").toLowerCase();
|
].join(" ").toLowerCase();
|
||||||
const matchesSearch = !search || haystack.includes(search);
|
const matchesSearch = !query || haystack.includes(query);
|
||||||
const matchesSeverity = severity === "all" || row.dataset.severity === severity;
|
const matchesSeverity = severity === "all" || alert.severity === severity;
|
||||||
const matchesStatus = status === "all" || row.dataset.status === status;
|
const matchesStatus = status === "all" || alert.status === status;
|
||||||
const matchesGroup = group === "all" || row.dataset.group === group;
|
const matchesGroup = group === "all" || alert.group === group;
|
||||||
row.style.display = matchesSearch && matchesSeverity && matchesStatus && matchesGroup ? "" : "none";
|
return matchesSearch && matchesSeverity && matchesStatus && matchesGroup;
|
||||||
});
|
});
|
||||||
|
|
||||||
const order = { high: 3, medium: 2, low: 1 };
|
const order = { high: 3, medium: 2, low: 1 };
|
||||||
visibleRows().sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
if (sortFilter.value === "severity") return order[b.dataset.severity] - order[a.dataset.severity];
|
if (sortFilter.value === "severity") return (order[b.severity] || 0) - (order[a.severity] || 0);
|
||||||
if (sortFilter.value === "oldest") return Number(a.dataset.id) - Number(b.dataset.id);
|
if (sortFilter.value === "oldest") return String(a.created_at).localeCompare(String(b.created_at));
|
||||||
return Number(b.dataset.id) - Number(a.dataset.id);
|
return String(b.created_at).localeCompare(String(a.created_at));
|
||||||
}).forEach((row) => alertsBody.appendChild(row));
|
});
|
||||||
|
return rows;
|
||||||
const selectedVisible = visibleRows().find((row) => row.classList.contains("is-selected"));
|
|
||||||
if (!selectedVisible && visibleRows()[0]) updateDetails(visibleRows()[0]);
|
|
||||||
updateSelectedCount();
|
|
||||||
updateSummaryCounts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRowStatus(row, nextStatus) {
|
function ensureActive(rows) {
|
||||||
row.dataset.status = nextStatus;
|
if (rows.length === 0) {
|
||||||
const statusCell = row.children[3]?.querySelector(".alerts-pill");
|
state.activeID = null;
|
||||||
if (!statusCell) return;
|
return null;
|
||||||
statusCell.className = `alerts-pill ${nextStatus}`;
|
}
|
||||||
statusCell.textContent = nextStatus;
|
const found = rows.find((item) => item.id === state.activeID);
|
||||||
|
if (found) return found;
|
||||||
|
state.activeID = rows[0].id;
|
||||||
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSelectedStatus(nextStatus) {
|
function render() {
|
||||||
const rows = selectedRows();
|
const rows = filteredAlerts();
|
||||||
if (!rows.length) {
|
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");
|
showToast("Select one or more alerts first", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "open-only") {
|
||||||
rows.forEach((row) => {
|
statusFilter.value = "open";
|
||||||
setRowStatus(row, nextStatus);
|
render();
|
||||||
row.querySelector(".row-check").checked = false;
|
showToast("Showing open alerts only");
|
||||||
});
|
return;
|
||||||
if (selectAll) selectAll.checked = false;
|
}
|
||||||
updateSelectedCount();
|
if (action === "refresh") {
|
||||||
updateSummaryCounts();
|
window.location.reload();
|
||||||
|
return;
|
||||||
const currentRow = visibleRows().find((row) => row.classList.contains("is-selected")) || visibleRows()[0];
|
}
|
||||||
if (currentRow) updateDetails(currentRow);
|
if (action === "copy-meta") {
|
||||||
showToast(nextStatus === "acked" ? "Selected alerts acknowledged" : "Selected alerts closed");
|
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 = {
|
function escapeHtml(value) {
|
||||||
refresh: "Alerts refreshed in mock view",
|
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||||
export: "Visible alerts exported in mock view",
|
|
||||||
"copy-meta": "Metadata copied in mock view",
|
|
||||||
"help-codes": "Each alert code maps to a unique trigger point and trace identifier.",
|
|
||||||
"help-meta": "Metadata explains why the alert happened and includes extra context."
|
|
||||||
};
|
|
||||||
|
|
||||||
function runCommand(command) {
|
|
||||||
switch (command) {
|
|
||||||
case "ack":
|
|
||||||
changeSelectedStatus("acked");
|
|
||||||
return;
|
|
||||||
case "close":
|
|
||||||
changeSelectedStatus("closed");
|
|
||||||
return;
|
|
||||||
case "open-only":
|
|
||||||
statusFilter.value = "open";
|
|
||||||
applyFilters();
|
|
||||||
showToast("Showing open alerts only");
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
showToast(commandMessages[command] || `Mock action: ${command}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
[searchInput, severityFilter, statusFilter, sourceFilter, sortFilter].forEach((control) => {
|
||||||
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", applyFilters);
|
control.addEventListener(control.tagName === "INPUT" ? "input" : "change", render);
|
||||||
});
|
|
||||||
|
|
||||||
allRows().forEach((row) => {
|
|
||||||
row.addEventListener("click", (event) => {
|
|
||||||
if (event.target.closest("button") || event.target.closest("input")) return;
|
|
||||||
updateDetails(row);
|
|
||||||
});
|
|
||||||
row.querySelector(".row-open")?.addEventListener("click", () => updateDetails(row));
|
|
||||||
row.querySelector(".row-check")?.addEventListener("change", updateSelectedCount);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
selectAll?.addEventListener("change", () => {
|
selectAll?.addEventListener("change", () => {
|
||||||
visibleRows().forEach((row) => {
|
const rows = filteredAlerts();
|
||||||
const checkbox = row.querySelector(".row-check");
|
rows.forEach((alert) => {
|
||||||
if (checkbox) checkbox.checked = selectAll.checked;
|
if (selectAll.checked) state.selected.add(alert.id);
|
||||||
|
else state.selected.delete(alert.id);
|
||||||
});
|
});
|
||||||
updateSelectedCount();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", async () => {
|
||||||
menuController.close();
|
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 === "Escape") menuController.close();
|
||||||
if (event.key === "F5") {
|
if (event.key === "F5") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
runCommand("refresh");
|
await runAction("refresh");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
applyFilters();
|
render();
|
||||||
updateDetails(allRows()[0]);
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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() {
|
function selectedIDsOrActive() {
|
||||||
if (state.selected.size) return Array.from(state.selected);
|
if (state.selected.size) return Array.from(state.selected);
|
||||||
const active = currentActiveBox();
|
const active = currentActiveBox();
|
||||||
@@ -419,6 +448,10 @@
|
|||||||
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
|
if (!window.confirm("Delete selected boxes? This removes stored files.")) return;
|
||||||
await runBulkAction("delete", selectedIDsOrActive());
|
await runBulkAction("delete", selectedIDsOrActive());
|
||||||
return;
|
return;
|
||||||
|
case "cleanup-expired":
|
||||||
|
if (!window.confirm("Run cleanup for expired boxes now?")) return;
|
||||||
|
await runCleanupAction();
|
||||||
|
return;
|
||||||
case "help-scope":
|
case "help-scope":
|
||||||
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
|
showToast("Ownership filter waits for account + box owner data in backend", "info", 3400);
|
||||||
return;
|
return;
|
||||||
|
|||||||
314
static/js/admin/security.js
Normal file
314
static/js/admin/security.js
Normal 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();
|
||||||
|
})();
|
||||||
@@ -3,8 +3,7 @@
|
|||||||
const toastTarget = document.getElementById("toast");
|
const toastTarget = document.getElementById("toast");
|
||||||
const body = document.getElementById("users-body");
|
const body = document.getElementById("users-body");
|
||||||
const search = document.getElementById("users-search");
|
const search = document.getElementById("users-search");
|
||||||
const status = document.getElementById("users-status");
|
const statusFilter = document.getElementById("users-status");
|
||||||
const role = document.getElementById("users-role-filter");
|
|
||||||
const sort = document.getElementById("users-sort");
|
const sort = document.getElementById("users-sort");
|
||||||
const size = document.getElementById("users-size");
|
const size = document.getElementById("users-size");
|
||||||
const masterCheck = document.getElementById("users-master-check");
|
const masterCheck = document.getElementById("users-master-check");
|
||||||
@@ -14,61 +13,234 @@
|
|||||||
const prevBtn = document.getElementById("users-prev");
|
const prevBtn = document.getElementById("users-prev");
|
||||||
const nextBtn = document.getElementById("users-next");
|
const nextBtn = document.getElementById("users-next");
|
||||||
const selectVisible = document.getElementById("select-visible");
|
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 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 = [
|
const state = {
|
||||||
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
|
page: 1,
|
||||||
{ 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" },
|
users: [],
|
||||||
{ 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" },
|
selected: new Set(),
|
||||||
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
|
currentUserID: "",
|
||||||
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
|
};
|
||||||
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
|
|
||||||
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
|
|
||||||
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
|
|
||||||
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
|
|
||||||
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
|
|
||||||
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
|
|
||||||
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
|
|
||||||
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
|
|
||||||
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const state = { page: 1, selected: new Set() };
|
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") {
|
function toast(message, type = "info") {
|
||||||
if (window.WarpBoxUI) {
|
if (window.WarpBoxUI) {
|
||||||
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
|
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!toastTarget) return;
|
if (toastTarget) toastTarget.textContent = message;
|
||||||
toastTarget.textContent = message;
|
|
||||||
toastTarget.classList.add("is-visible");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filtered() {
|
function escapeHTML(value) {
|
||||||
const query = search.value.trim().toLowerCase();
|
return String(value || "")
|
||||||
const statusFilter = status.value;
|
.replaceAll("&", "&")
|
||||||
const roleFilter = role.value;
|
.replaceAll("<", "<")
|
||||||
const sortBy = sort.value;
|
.replaceAll(">", ">")
|
||||||
const rows = users.filter((user) => {
|
.replaceAll('"', """)
|
||||||
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
|
.replaceAll("'", "'");
|
||||||
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
|
}
|
||||||
const matchesRole = roleFilter === "all" || user.role === roleFilter;
|
|
||||||
return matchesQuery && matchesStatus && matchesRole;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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) => {
|
rows.sort((a, b) => {
|
||||||
if (sortBy === "createdDesc") return b.created.localeCompare(a.created);
|
if (sort.value === "createdDesc") return String(b.created_at).localeCompare(String(a.created_at));
|
||||||
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
|
if (sort.value === "lastSeenDesc") return String(b.last_seen_at || "").localeCompare(String(a.last_seen_at || ""));
|
||||||
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
|
if (sort.value === "keysDesc") return (b.api_key_count || 0) - (a.api_key_count || 0);
|
||||||
return a.username.localeCompare(b.username);
|
return a.username.localeCompare(b.username);
|
||||||
});
|
});
|
||||||
return rows;
|
return rows;
|
||||||
@@ -77,40 +249,56 @@
|
|||||||
function paged(rows) {
|
function paged(rows) {
|
||||||
const perPage = Number(size.value || 12);
|
const perPage = Number(size.value || 12);
|
||||||
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
||||||
if (state.page > pages) state.page = pages;
|
state.page = Math.max(1, Math.min(state.page, pages));
|
||||||
if (state.page < 1) state.page = 1;
|
|
||||||
const start = (state.page - 1) * perPage;
|
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) {
|
function permissionsSummary(permissions = {}) {
|
||||||
return `<span class="users-pill ${value}">${value}</span>`;
|
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) {
|
function renderRow(user) {
|
||||||
const checked = state.selected.has(user.id) ? " checked" : "";
|
const checked = state.selected.has(user.id) ? " checked" : "";
|
||||||
|
const active = user.id === state.currentUserID ? " is-selected" : "";
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
|
row.className = active;
|
||||||
|
row.dataset.userId = user.id;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td><input type="checkbox" class="row-check"${checked}></td>
|
<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><div class="users-username"><strong>${escapeHTML(user.username)}</strong><span class="users-muted">${escapeHTML(user.id)}</span></div></td>
|
||||||
<td title="${user.email}">${user.email}</td>
|
<td title="${escapeHTML(user.email)}">${escapeHTML(user.email)}</td>
|
||||||
<td>${statusPill(user.status)}</td>
|
<td><span class="users-pill ${escapeHTML(user.status)}">${escapeHTML(user.status)}</span></td>
|
||||||
<td>${user.role}</td>
|
<td>${escapeHTML(permissionsSummary(user.permissions))}</td>
|
||||||
<td>${user.plan}</td>
|
<td>${escapeHTML(limitsSummary(user.limits))}</td>
|
||||||
<td>${user.boxes}</td>
|
<td>${user.api_key_count || 0}</td>
|
||||||
<td>${user.lastSeen}</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="open">Open</button></div></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) => {
|
row.querySelector(".row-check")?.addEventListener("change", (event) => {
|
||||||
if (event.target.checked) state.selected.add(user.id);
|
if (event.target.checked) state.selected.add(user.id);
|
||||||
else state.selected.delete(user.id);
|
else state.selected.delete(user.id);
|
||||||
|
if (event.target.checked) state.currentUserID = user.id;
|
||||||
|
populateSelectedPanels();
|
||||||
syncSelected();
|
syncSelected();
|
||||||
syncMasterCheck();
|
syncMasterCheck();
|
||||||
|
render();
|
||||||
});
|
});
|
||||||
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
|
row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit"));
|
||||||
toast(`Mock user preview: ${user.username}`);
|
row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys"));
|
||||||
});
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,19 +311,11 @@
|
|||||||
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
|
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() {
|
function render() {
|
||||||
const rows = filtered();
|
const rows = filteredUsers();
|
||||||
const page = paged(rows);
|
const page = paged(rows);
|
||||||
body.innerHTML = "";
|
body.innerHTML = "";
|
||||||
page.rows.forEach((user) => body.appendChild(renderRow(user)));
|
page.rows.forEach((user) => body.appendChild(renderRow(user)));
|
||||||
|
|
||||||
visiblePill.textContent = `${rows.length} visible`;
|
visiblePill.textContent = `${rows.length} visible`;
|
||||||
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
|
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
|
||||||
prevBtn.disabled = state.page <= 1;
|
prevBtn.disabled = state.page <= 1;
|
||||||
@@ -145,75 +325,109 @@
|
|||||||
syncMasterCheck();
|
syncMasterCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
async function fetchUsers() {
|
||||||
search.value = "";
|
const result = await api("/admin/users/list");
|
||||||
status.value = "all";
|
state.users = result.users || [];
|
||||||
role.value = "all";
|
if (state.currentUserID && !state.users.some((user) => user.id === state.currentUserID)) {
|
||||||
sort.value = "username";
|
state.currentUserID = "";
|
||||||
state.page = 1;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyBulk(nextStatus) {
|
|
||||||
const selected = users.filter((user) => state.selected.has(user.id));
|
|
||||||
if (!selected.length) {
|
|
||||||
toast("Select one or more users first", "warning");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
selected.forEach((user) => { user.status = nextStatus; });
|
state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id)));
|
||||||
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
|
|
||||||
renderStats();
|
renderStats();
|
||||||
|
populateSelectedPanels();
|
||||||
render();
|
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) {
|
switch (command) {
|
||||||
case "invite":
|
case "tab-add":
|
||||||
modeInput.value = "invite";
|
|
||||||
toast("Invite mode selected");
|
|
||||||
break;
|
|
||||||
case "create":
|
case "create":
|
||||||
modeInput.value = "create";
|
setTab("add");
|
||||||
toast("Create mode selected");
|
|
||||||
break;
|
|
||||||
case "export":
|
|
||||||
toast("Mock CSV export complete");
|
|
||||||
break;
|
|
||||||
case "bulk-disable":
|
|
||||||
applyBulk("disabled");
|
|
||||||
break;
|
|
||||||
case "bulk-enable":
|
|
||||||
applyBulk("active");
|
|
||||||
break;
|
|
||||||
case "bulk-revoke":
|
|
||||||
toast("Mock session revocation queued");
|
|
||||||
break;
|
break;
|
||||||
case "refresh":
|
case "refresh":
|
||||||
|
await fetchUsers();
|
||||||
toast("Users list refreshed");
|
toast("Users list refreshed");
|
||||||
render();
|
|
||||||
break;
|
break;
|
||||||
case "pending-only":
|
case "bulk-disable":
|
||||||
status.value = "pending";
|
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;
|
state.page = 1;
|
||||||
render();
|
render();
|
||||||
break;
|
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:
|
default:
|
||||||
toast(`Mock action: ${command}`);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[search, status, role, sort, size].forEach((el) => {
|
document.querySelectorAll(".users-tab").forEach((tab) => {
|
||||||
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
|
tab.addEventListener("click", () => setTab(tab.dataset.tab));
|
||||||
|
});
|
||||||
|
|
||||||
|
[search, statusFilter, sort, size].forEach((element) => {
|
||||||
|
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", () => {
|
||||||
state.page = 1;
|
state.page = 1;
|
||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
@@ -231,74 +445,131 @@
|
|||||||
|
|
||||||
masterCheck.addEventListener("change", () => {
|
masterCheck.addEventListener("change", () => {
|
||||||
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||||
|
const userID = row.dataset.userId || "";
|
||||||
const checkbox = row.querySelector(".row-check");
|
const checkbox = row.querySelector(".row-check");
|
||||||
if (!checkbox) return;
|
if (!checkbox || !userID) return;
|
||||||
checkbox.checked = masterCheck.checked;
|
checkbox.checked = masterCheck.checked;
|
||||||
const userID = row.querySelector(".users-muted")?.textContent || "";
|
|
||||||
if (masterCheck.checked) state.selected.add(userID);
|
if (masterCheck.checked) state.selected.add(userID);
|
||||||
else state.selected.delete(userID);
|
else state.selected.delete(userID);
|
||||||
});
|
});
|
||||||
syncSelected();
|
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
|
||||||
|
populateSelectedPanels();
|
||||||
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
selectVisible.addEventListener("click", () => {
|
selectVisible.addEventListener("click", () => {
|
||||||
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||||
|
const userID = row.dataset.userId || "";
|
||||||
const checkbox = row.querySelector(".row-check");
|
const checkbox = row.querySelector(".row-check");
|
||||||
const userID = row.querySelector(".users-muted")?.textContent || "";
|
if (!checkbox || !userID) return;
|
||||||
if (!checkbox) return;
|
|
||||||
checkbox.checked = true;
|
checkbox.checked = true;
|
||||||
state.selected.add(userID);
|
state.selected.add(userID);
|
||||||
});
|
});
|
||||||
syncSelected();
|
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
|
||||||
syncMasterCheck();
|
populateSelectedPanels();
|
||||||
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener("submit", (event) => {
|
addForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const username = usernameInput.value.trim();
|
const username = fields.add.username.value.trim();
|
||||||
const email = emailInput.value.trim();
|
const email = fields.add.email.value.trim();
|
||||||
const mode = modeInput.value;
|
|
||||||
if (!username || !email) {
|
if (!username || !email) {
|
||||||
toast("Username and email are required", "warning");
|
toast("Username and email are required", "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
users.unshift({
|
try {
|
||||||
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
|
await saveUser({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
status: mode === "invite" ? "pending" : "active",
|
status: fields.add.status.value,
|
||||||
role: roleInput.value,
|
max_file_size_mb: numericMB(fields.add.maxFile),
|
||||||
plan: planInput.value,
|
max_box_size_mb: numericMB(fields.add.maxBox),
|
||||||
boxes: 0,
|
permissions: permissionPayload(fields.add),
|
||||||
created: new Date().toISOString().slice(0, 10),
|
}, "User created");
|
||||||
lastSeen: "never"
|
addForm.reset();
|
||||||
});
|
fields.add.status.value = "active";
|
||||||
form.reset();
|
fields.add.maxFile.value = "0";
|
||||||
modeInput.value = "invite";
|
fields.add.maxBox.value = "0";
|
||||||
renderStats();
|
[fields.add.web, fields.add.api, fields.add.create, fields.add.upload].forEach((item) => { item.checked = true; });
|
||||||
render();
|
setTab("edit");
|
||||||
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
|
} 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) => {
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", async () => {
|
||||||
menuController.close();
|
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 === "Escape") menuController.close();
|
||||||
if (event.key === "F5") {
|
if (event.key === "F5") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
runCommand("refresh");
|
await runCommand("refresh");
|
||||||
}
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
|
|
||||||
event.preventDefault();
|
|
||||||
runCommand("invite");
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renderStats();
|
fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning"));
|
||||||
render();
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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() {
|
async function createBox() {
|
||||||
const response = await fetch("/box", {
|
const response = await fetch("/box", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
retention_key: el.expiry?.value || defaultRetention,
|
retention_key: el.expiry?.value || defaultRetention,
|
||||||
password: el.password?.value || "",
|
password: el.password?.value || "",
|
||||||
@@ -28,7 +36,7 @@ async function markFileStatus(item, status) {
|
|||||||
try {
|
try {
|
||||||
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
|
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -62,6 +70,8 @@ function uploadFile(item, onComplete) {
|
|||||||
formData.append("file", item.file, item.displayName);
|
formData.append("file", item.file, item.displayName);
|
||||||
|
|
||||||
xhr.open("POST", item.boxFile.upload_path);
|
xhr.open("POST", item.boxFile.upload_path);
|
||||||
|
const headers = authHeaders();
|
||||||
|
if (headers.Authorization) xhr.setRequestHeader("Authorization", headers.Authorization);
|
||||||
|
|
||||||
xhr.upload.addEventListener("loadstart", () => {
|
xhr.upload.addEventListener("loadstart", () => {
|
||||||
item.loaded = 0;
|
item.loaded = 0;
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ function setBoxOptionsLocked(locked) {
|
|||||||
function updateDisabledReasons() {
|
function updateDisabledReasons() {
|
||||||
if (el.startButton) {
|
if (el.startButton) {
|
||||||
let reason = "";
|
let reason = "";
|
||||||
|
const policyMessage = apiKeyPolicyMessage();
|
||||||
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
|
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
|
||||||
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
|
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 (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.";
|
else if (!files.length) reason = "There are no files selected. Please select files to upload.";
|
||||||
el.startButton.disabled = false;
|
el.startButton.disabled = false;
|
||||||
@@ -101,6 +103,13 @@ function syncMenuChecks() {
|
|||||||
function syncApiKeyField() {
|
function syncApiKeyField() {
|
||||||
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
|
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
|
||||||
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
|
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
|
||||||
|
if (!el.apiKeyMode?.checked) {
|
||||||
|
clearTimeout(apiKeyTimer);
|
||||||
|
apiKeyValidationRun += 1;
|
||||||
|
resetAccountLimits();
|
||||||
|
updateLimitHint();
|
||||||
|
renderFiles();
|
||||||
|
}
|
||||||
if (el.apiKeyInput) {
|
if (el.apiKeyInput) {
|
||||||
el.apiKeyInput.disabled = !enabled;
|
el.apiKeyInput.disabled = !enabled;
|
||||||
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
|
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");
|
wrapper?.classList.remove("is-checking");
|
||||||
|
|
||||||
if (!el.apiKeyMode?.checked) {
|
if (!el.apiKeyMode?.checked) {
|
||||||
|
apiKeyValidationRun += 1;
|
||||||
|
resetAccountLimits();
|
||||||
el.apiKeyState.textContent = "";
|
el.apiKeyState.textContent = "";
|
||||||
|
updateLimitHint();
|
||||||
|
renderFiles();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const value = el.apiKeyInput.value.trim();
|
const value = el.apiKeyInput.value.trim();
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
apiKeyValidationRun += 1;
|
||||||
|
resetAccountLimits();
|
||||||
el.apiKeyState.textContent = "waiting";
|
el.apiKeyState.textContent = "waiting";
|
||||||
|
updateLimitHint();
|
||||||
|
renderFiles();
|
||||||
saveSettings();
|
saveSettings();
|
||||||
return;
|
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;
|
el.apiKeyInput.disabled = true;
|
||||||
wrapper?.classList.add("is-checking");
|
wrapper?.classList.add("is-checking");
|
||||||
el.apiKeyState.textContent = "checking";
|
el.apiKeyState.textContent = "checking";
|
||||||
apiKeyTimer = setTimeout(() => {
|
apiKeyTimer = setTimeout(async () => {
|
||||||
wrapper?.classList.remove("is-checking");
|
try {
|
||||||
el.apiKeyInput.disabled = uploadLocked;
|
const response = await fetch("/auth/me", {
|
||||||
if (validApiKey(value)) {
|
headers: { Authorization: `Bearer ${value}` },
|
||||||
el.apiKeyState.textContent = "saved locally";
|
});
|
||||||
|
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();
|
saveSettings();
|
||||||
} else {
|
setStatus(`${payload.user.username || payload.user.email} limits: file ${fileText}, box ${boxText}`);
|
||||||
el.apiKeyInput.value = "";
|
if (policyMessage) showToast(policyMessage, "warning");
|
||||||
el.apiKeyState.textContent = "invalid";
|
} catch (_) {
|
||||||
saveSettings();
|
if (runID !== apiKeyValidationRun) return;
|
||||||
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
|
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);
|
}, 650);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,16 +44,20 @@ const el = {
|
|||||||
|
|
||||||
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
|
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
|
||||||
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
|
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
|
||||||
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
|
const baseMaxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
|
||||||
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
|
const baseMaxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
|
||||||
const oneTimeRetentionKey = "one-time";
|
const oneTimeRetentionKey = "one-time";
|
||||||
|
|
||||||
|
let maxFileBytes = baseMaxFileBytes;
|
||||||
|
let maxBoxBytes = baseMaxBoxBytes;
|
||||||
let files = [];
|
let files = [];
|
||||||
let shareUrl = "";
|
let shareUrl = "";
|
||||||
let uploadLocked = false;
|
let uploadLocked = false;
|
||||||
let statusTimer = null;
|
let statusTimer = null;
|
||||||
let pendingDuplicateFiles = [];
|
let pendingDuplicateFiles = [];
|
||||||
let apiKeyTimer = null;
|
let apiKeyTimer = null;
|
||||||
|
let apiKeyValidationRun = 0;
|
||||||
|
let authenticatedUser = null;
|
||||||
let completedImpactKeys = new Set();
|
let completedImpactKeys = new Set();
|
||||||
let overallImpactDone = false;
|
let overallImpactDone = false;
|
||||||
|
|
||||||
@@ -105,6 +109,33 @@ function hasQuotaError() {
|
|||||||
return isOverBoxQuota() || oversizedFiles().length > 0;
|
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) {
|
function normalizedFileName(name) {
|
||||||
return String(name || "").trim().toLowerCase();
|
return String(name || "").trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
92
templates/admin/activity.html
Normal file
92
templates/admin/activity.html
Normal 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 }}
|
||||||
@@ -61,22 +61,22 @@
|
|||||||
<section class="alerts-summary-grid" aria-label="Alerts summary">
|
<section class="alerts-summary-grid" aria-label="Alerts summary">
|
||||||
<article class="alerts-stat-card is-danger">
|
<article class="alerts-stat-card is-danger">
|
||||||
<p class="alerts-stat-label">Open alerts</p>
|
<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>
|
<p class="alerts-stat-note">Requires attention</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="alerts-stat-card is-warning">
|
<article class="alerts-stat-card is-warning">
|
||||||
<p class="alerts-stat-label">High severity</p>
|
<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>
|
<p class="alerts-stat-note">Escalate first</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="alerts-stat-card is-info">
|
<article class="alerts-stat-card is-info">
|
||||||
<p class="alerts-stat-label">Acknowledged</p>
|
<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>
|
<p class="alerts-stat-note">Seen but not closed</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="alerts-stat-card is-info">
|
<article class="alerts-stat-card is-info">
|
||||||
<p class="alerts-stat-label">Closed today</p>
|
<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>
|
<p class="alerts-stat-note">History stays lightweight</p>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -134,108 +134,7 @@
|
|||||||
<th class="alerts-col-actions">Actions</th>
|
<th class="alerts-col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="alerts-body">
|
<tbody id="alerts-body"></tbody>
|
||||||
<tr data-id="10" data-severity="high" data-status="open" data-group="storage" data-title="Storage connector unavailable" data-description="Primary local storage connector failed health check and new writes are paused." data-code="301" data-trace="storage.connector.health_failed" data-time="today 14:08" data-metadata='{"connector":"local-main","mode":"read_only","retry_in":"30s"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Storage connector unavailable</td>
|
|
||||||
<td><span class="alerts-pill high">high</span></td>
|
|
||||||
<td><span class="alerts-pill open">open</span></td>
|
|
||||||
<td>301</td>
|
|
||||||
<td>storage.connector.health_failed</td>
|
|
||||||
<td>today 14:08</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="9" data-severity="medium" data-status="open" data-group="thumbnails" data-title="Thumbnail generation failed" data-description="Thumbnail generation failed for one uploaded image. Original file remains available." data-code="601" data-trace="thumbnail.generate.failed" data-time="today 13:40" data-metadata='{"box":"bx_49aa","file":"poster.png","worker":"thumb-2"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Thumbnail generation failed</td>
|
|
||||||
<td><span class="alerts-pill medium">medium</span></td>
|
|
||||||
<td><span class="alerts-pill open">open</span></td>
|
|
||||||
<td>601</td>
|
|
||||||
<td>thumbnail.generate.failed</td>
|
|
||||||
<td>today 13:40</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="8" data-severity="low" data-status="acked" data-group="uploads" data-title="Large upload nearing account cap" data-description="A user is close to their daily upload budget." data-code="124" data-trace="upload.quota.nearing_cap" data-time="today 12:58" data-metadata='{"user":"geo","used":"44 GB","limit":"50 GB"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Large upload nearing account cap</td>
|
|
||||||
<td><span class="alerts-pill low">low</span></td>
|
|
||||||
<td><span class="alerts-pill acked">acked</span></td>
|
|
||||||
<td>124</td>
|
|
||||||
<td>upload.quota.nearing_cap</td>
|
|
||||||
<td>today 12:58</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="7" data-severity="high" data-status="open" data-group="auth" data-title="Repeated admin login failures" data-description="Multiple failed admin login attempts were detected from the same source." data-code="211" data-trace="auth.admin.failed_login_burst" data-time="today 12:10" data-metadata='{"ip":"198.51.100.4","attempts":7,"window":"10m"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Repeated admin login failures</td>
|
|
||||||
<td><span class="alerts-pill high">high</span></td>
|
|
||||||
<td><span class="alerts-pill open">open</span></td>
|
|
||||||
<td>211</td>
|
|
||||||
<td>auth.admin.failed_login_burst</td>
|
|
||||||
<td>today 12:10</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="6" data-severity="medium" data-status="acked" data-group="storage" data-title="Cleanup skipped locked files" data-description="Cleanup job encountered locked files and skipped them." data-code="342" data-trace="cleanup.skip.locked_files" data-time="today 10:22" data-metadata='{"count":3,"connector":"local-main"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Cleanup skipped locked files</td>
|
|
||||||
<td><span class="alerts-pill medium">medium</span></td>
|
|
||||||
<td><span class="alerts-pill acked">acked</span></td>
|
|
||||||
<td>342</td>
|
|
||||||
<td>cleanup.skip.locked_files</td>
|
|
||||||
<td>today 10:22</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="5" data-severity="low" data-status="closed" data-group="uploads" data-title="Archive completed with warnings" data-description="ZIP archive completed but excluded one unreadable temporary file." data-code="145" data-trace="archive.complete.with_warning" data-time="today 09:02" data-metadata='{"box":"bx_3901","skipped":1}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Archive completed with warnings</td>
|
|
||||||
<td><span class="alerts-pill low">low</span></td>
|
|
||||||
<td><span class="alerts-pill closed">closed</span></td>
|
|
||||||
<td>145</td>
|
|
||||||
<td>archive.complete.with_warning</td>
|
|
||||||
<td>today 09:02</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="4" data-severity="medium" data-status="open" data-group="uploads" data-title="Upload session expired mid-transfer" data-description="A long-running upload lost session validity before final commit." data-code="156" data-trace="upload.session.expired_mid_transfer" data-time="yesterday" data-metadata='{"user":"teo","partial_bytes":"1.2 GB"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Upload session expired mid-transfer</td>
|
|
||||||
<td><span class="alerts-pill medium">medium</span></td>
|
|
||||||
<td><span class="alerts-pill open">open</span></td>
|
|
||||||
<td>156</td>
|
|
||||||
<td>upload.session.expired_mid_transfer</td>
|
|
||||||
<td>yesterday</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="3" data-severity="low" data-status="closed" data-group="thumbnails" data-title="Thumbnail worker restarted" data-description="Thumbnail worker restarted after a normal watchdog recycle." data-code="602" data-trace="thumbnail.worker.restarted" data-time="yesterday" data-metadata='{"worker":"thumb-1","reason":"watchdog"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Thumbnail worker restarted</td>
|
|
||||||
<td><span class="alerts-pill low">low</span></td>
|
|
||||||
<td><span class="alerts-pill closed">closed</span></td>
|
|
||||||
<td>602</td>
|
|
||||||
<td>thumbnail.worker.restarted</td>
|
|
||||||
<td>yesterday</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="2" data-severity="medium" data-status="acked" data-group="auth" data-title="User invited without email delivery confirmation" data-description="Invite creation succeeded but email delivery confirmation was not returned." data-code="224" data-trace="auth.invite.delivery_unknown" data-time="2 days ago" data-metadata='{"user":"reo","provider":"smtp-primary"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>User invited without email delivery confirmation</td>
|
|
||||||
<td><span class="alerts-pill medium">medium</span></td>
|
|
||||||
<td><span class="alerts-pill acked">acked</span></td>
|
|
||||||
<td>224</td>
|
|
||||||
<td>auth.invite.delivery_unknown</td>
|
|
||||||
<td>2 days ago</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
<tr data-id="1" data-severity="low" data-status="closed" data-group="storage" data-title="Secondary connector caught up" data-description="Delayed sync on a secondary storage connector completed successfully." data-code="329" data-trace="storage.secondary.sync_recovered" data-time="2 days ago" data-metadata='{"connector":"bucket-archive","lag":"0"}'>
|
|
||||||
<td><input type="checkbox" class="row-check"></td>
|
|
||||||
<td>Secondary connector caught up</td>
|
|
||||||
<td><span class="alerts-pill low">low</span></td>
|
|
||||||
<td><span class="alerts-pill closed">closed</span></td>
|
|
||||||
<td>329</td>
|
|
||||||
<td>storage.secondary.sync_recovered</td>
|
|
||||||
<td>2 days ago</td>
|
|
||||||
<td><button class="win98-button alerts-row-button row-open" type="button">Open</button></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,10 +186,11 @@
|
|||||||
<div class="alerts-action-stack">
|
<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="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="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>
|
<button class="win98-button alerts-action-button" type="button" data-command="refresh">Refresh alerts</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="alerts-mini-note">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -301,11 +201,12 @@
|
|||||||
<div class="alerts-footerbar">
|
<div class="alerts-footerbar">
|
||||||
<div class="alerts-footer-left">
|
<div class="alerts-footer-left">
|
||||||
<span class="alerts-status-pill" id="selected-count">Selected: 0</span>
|
<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>
|
||||||
<div class="alerts-footer-right">
|
<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="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="close">Close</button>
|
||||||
|
<button class="win98-button alerts-footer-button" type="button" data-command="delete">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,6 +215,7 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script id="alerts-data" type="application/json">{{ toJSON .Alerts }}</script>
|
||||||
<script src="/static/js/warpbox-ui.js"></script>
|
<script src="/static/js/warpbox-ui.js"></script>
|
||||||
<script src="/static/js/admin/alerts.js"></script>
|
<script src="/static/js/admin/alerts.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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-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>
|
<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>
|
<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>
|
<button class="menu-action" type="button" data-command="delete"><span>D</span><span>Delete selected</span><span></span></button>
|
||||||
</div>
|
</div>
|
||||||
</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="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="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="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>
|
<button class="win98-button boxes-tool-button is-danger" type="button" data-command="delete">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</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" 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>
|
<button class="win98-button boxes-action-button is-danger" type="button" data-command="active-delete">Delete</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
<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 "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 "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 "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 "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>
|
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="admin-taskbar-session" aria-label="Admin session summary">
|
<div class="admin-taskbar-session" aria-label="Admin session summary">
|
||||||
|
|||||||
179
templates/admin/security.html
Normal file
179
templates/admin/security.html
Normal 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. WarpBox will trust forwarding headers only when the direct remote IP is in this list.</p>
|
||||||
|
<pre>Caddyfile
|
||||||
|
:443 {
|
||||||
|
reverse_proxy 127.0.0.1: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 }}
|
||||||
@@ -35,99 +35,110 @@
|
|||||||
<div class="menu-item">
|
<div class="menu-item">
|
||||||
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
<button class="menu-button" type="button" aria-expanded="false">File</button>
|
||||||
<div class="menu-popup">
|
<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="tab-add"><span>N</span><span>New user</span><span></span></button>
|
||||||
<button class="menu-action" type="button" data-command="create"><span>C</span><span>Create local user</span><span></span></button>
|
|
||||||
<div class="menu-separator"></div>
|
<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>
|
</div>
|
||||||
<div class="menu-item">
|
<div class="menu-item">
|
||||||
<button class="menu-button" type="button" aria-expanded="false">Users</button>
|
<button class="menu-button" type="button" aria-expanded="false">Users</button>
|
||||||
<div class="menu-popup">
|
<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-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-enable"><span>E</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-delete"><span>X</span><span>Delete selected</span><span></span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item">
|
<div class="menu-item">
|
||||||
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
<button class="menu-button" type="button" aria-expanded="false">View</button>
|
||||||
<div class="menu-popup">
|
<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="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>
|
<button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</span><span></span></button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</nav>
|
||||||
|
|
||||||
<div class="admin-workspace-body users-page-body">
|
<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">
|
<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-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-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>
|
<article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="users-main-grid">
|
<section class="users-main-grid">
|
||||||
<section class="users-panel">
|
<aside class="users-control-panel" aria-label="User actions">
|
||||||
<div class="users-panel-header">
|
<div class="users-selected-card">
|
||||||
<div class="users-panel-title">Create or invite <span>mock only</span></div>
|
<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>
|
||||||
<div class="users-panel-body">
|
<div class="users-side-tabs" role="tablist" aria-label="User panels">
|
||||||
<form id="users-form" class="users-form-grid">
|
<button class="users-tab is-active" type="button" data-tab="add">Add New</button>
|
||||||
<label class="users-field">Mode
|
<button class="users-tab" type="button" data-tab="edit">Edit</button>
|
||||||
<select class="users-select" id="users-mode">
|
<button class="users-tab" type="button" data-tab="policies">Policies</button>
|
||||||
<option value="invite">Send invite</option>
|
<button class="users-tab" type="button" data-tab="keys">API Keys</button>
|
||||||
<option value="create">Create local user</option>
|
</div>
|
||||||
</select>
|
|
||||||
</label>
|
<section class="users-tab-panel is-active" data-panel="add">
|
||||||
<label class="users-field">Username<input class="users-input" id="users-username" type="text" autocomplete="off"></label>
|
<div class="users-panel-header compact"><div class="users-panel-title">Add New</div></div>
|
||||||
<label class="users-field">Email<input class="users-input" id="users-email" type="email" autocomplete="off"></label>
|
<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">
|
<div class="users-row-two">
|
||||||
<label class="users-field">Role
|
<label class="users-field">Status<select class="users-select" id="add-status"><option value="active">active</option><option value="disabled">disabled</option></select></label>
|
||||||
<select class="users-select" id="users-role">
|
<label class="users-field">Max file MB<input class="users-input" id="add-max-file" type="number" min="0" step="1" value="0"></label>
|
||||||
<option value="uploader">uploader</option>
|
|
||||||
<option value="operator">operator</option>
|
|
||||||
<option value="viewer">viewer</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="users-field">Plan
|
|
||||||
<select class="users-select" id="users-plan">
|
|
||||||
<option value="standard">standard</option>
|
|
||||||
<option value="trusted">trusted</option>
|
|
||||||
<option value="guest-like">guest-like</option>
|
|
||||||
<option value="unlimited">unlimited</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</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">
|
<div class="users-form-actions">
|
||||||
<button class="win98-button users-action-button" type="reset">Clear</button>
|
<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>
|
</div>
|
||||||
</form>
|
</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">
|
<section class="users-panel">
|
||||||
<div class="users-panel-header">
|
<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" 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-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-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>
|
</div>
|
||||||
<div class="users-panel-body users-list-body">
|
<div class="users-panel-body users-list-body">
|
||||||
<div class="users-toolbar-grid">
|
<div class="users-toolbar-grid">
|
||||||
<input class="users-input" id="users-search" type="search" placeholder="Search username or email">
|
<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-status"><option value="all">all statuses</option><option value="active">active</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="keysDesc">api keys</option></select>
|
||||||
<select class="users-select" id="users-sort"><option value="username">sort username</option><option value="createdDesc">newest first</option><option value="lastSeenDesc">last seen</option><option value="boxesDesc">box count</option></select>
|
|
||||||
<select class="users-select" id="users-size"><option value="8">8 rows</option><option value="12" selected>12 rows</option><option value="20">20 rows</option></select>
|
<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>
|
||||||
<div class="users-table-wrap">
|
<div class="users-table-wrap">
|
||||||
@@ -155,9 +165,9 @@
|
|||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Role</th>
|
<th>Permissions</th>
|
||||||
<th>Plan</th>
|
<th>Limits</th>
|
||||||
<th>Boxes</th>
|
<th>Keys</th>
|
||||||
<th>Last seen</th>
|
<th>Last seen</th>
|
||||||
<th class="users-col-actions">Actions</th>
|
<th class="users-col-actions">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -179,15 +189,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="status-bar admin-dashboard-statusbar">
|
<footer class="status-bar admin-dashboard-statusbar">
|
||||||
<span id="users-status-left">Ready. Client-side mock data only.</span>
|
<span id="users-status-left">Ready.</span>
|
||||||
<span>server paging planned</span>
|
<span>real user store</span>
|
||||||
<span>admin only</span>
|
<span>admin only</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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/warpbox-ui.js"></script>
|
||||||
<script src="/static/js/admin/users.js"></script>
|
<script src="/static/js/admin/users.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@
|
|||||||
<div class="win98-statusbar upload-statusbar">
|
<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 id="status-text">{{ if .UploadsEnabled }}Ready · drag files anywhere onto the window{{ else }}Guest uploads are disabled{{ end }}</span>
|
||||||
<span>WarpBox</span>
|
<span>WarpBox</span>
|
||||||
|
<span>{{ .AppVersion }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -186,7 +187,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="option-check">
|
<label class="option-check">
|
||||||
<input type="checkbox" id="api-key-mode">
|
<input type="checkbox" id="api-key-mode">
|
||||||
<span>Use API key for larger quota</span>
|
<span>Use API key account limits</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="option-row api-key-row" id="api-key-row">
|
<label class="option-row api-key-row" id="api-key-row">
|
||||||
<span>API key:</span>
|
<span>API key:</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user