Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10ed806153 | |||
| 2d04a42736 |
@@ -30,3 +30,4 @@ WARPBOX_USER_STORAGE_BACKEND=local
|
||||
WARPBOX_READ_TIMEOUT=15s
|
||||
WARPBOX_WRITE_TIMEOUT=60s
|
||||
WARPBOX_IDLE_TIMEOUT=120s
|
||||
WARPBOX_TRUSTED_PROXIES=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,5 +12,6 @@ backend/static/uploads/*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.prod.env
|
||||
scripts/env/dev.env
|
||||
docker-compose.yml
|
||||
|
||||
72
README.md
72
README.md
@@ -33,6 +33,7 @@ Upload policy defaults are also configured in megabytes and can later be changed
|
||||
- `WARPBOX_SHORT_WINDOW_SECONDS=60`
|
||||
- `WARPBOX_ANONYMOUS_STORAGE_BACKEND=local`
|
||||
- `WARPBOX_USER_STORAGE_BACKEND=local`
|
||||
- `WARPBOX_TRUSTED_PROXIES=` controls whether forwarded client IP headers are accepted only from specific proxy IPs/CIDRs. See [SECURITY_PROXY.md](./SECURITY_PROXY.md).
|
||||
|
||||
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
|
||||
The dev script resolves that path from the repository root.
|
||||
@@ -74,6 +75,73 @@ The compose example also works with Podman compatible compose tools. Its data vo
|
||||
The image exposes `/health`, `/healthz`, and `/api/v1/health`. Docker and compose healthchecks
|
||||
use `/health`.
|
||||
|
||||
## Reverse Proxy Security
|
||||
|
||||
Warpbox uses the resolved client IP for anonymous limits, manual bans, and automatic bans. The
|
||||
default behavior trusts `X-Forwarded-For` and `X-Real-IP` so a normal Caddy reverse proxy works
|
||||
without extra setup. For hardened deployments where the app port might be reachable from more than
|
||||
one network, set `WARPBOX_TRUSTED_PROXIES` to trusted proxy IPs/CIDRs. See
|
||||
[SECURITY_PROXY.md](./SECURITY_PROXY.md) for Caddy examples and Docker/systemd notes.
|
||||
|
||||
## Systemd
|
||||
|
||||
Build the binary on the server, create a dedicated user, and keep runtime data outside the repo:
|
||||
|
||||
```bash
|
||||
cd /opt/warpbox-dev/backend
|
||||
go build -o /usr/local/bin/warpbox ./cmd/warpbox
|
||||
sudo useradd --system --home /var/lib/warpbox --shell /usr/sbin/nologin warpbox
|
||||
sudo mkdir -p /var/lib/warpbox /etc/warpbox
|
||||
sudo chown -R warpbox:warpbox /var/lib/warpbox
|
||||
sudo cp /opt/warpbox-dev/.env.example /etc/warpbox/warpbox.env
|
||||
```
|
||||
|
||||
Example `/etc/warpbox/warpbox.env` values:
|
||||
|
||||
```env
|
||||
WARPBOX_ENV=production
|
||||
WARPBOX_ADDR=127.0.0.1:6070
|
||||
WARPBOX_BASE_URL=https://warpbox.dev
|
||||
WARPBOX_DATA_DIR=/var/lib/warpbox
|
||||
WARPBOX_STATIC_DIR=/opt/warpbox-dev/backend/static
|
||||
WARPBOX_TEMPLATE_DIR=/opt/warpbox-dev/backend/templates
|
||||
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1
|
||||
```
|
||||
|
||||
Example `/etc/systemd/system/warpbox.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Warpbox file sharing service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=warpbox
|
||||
Group=warpbox
|
||||
EnvironmentFile=/etc/warpbox/warpbox.env
|
||||
ExecStart=/usr/local/bin/warpbox
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/warpbox
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Then enable it:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now warpbox
|
||||
sudo systemctl status warpbox
|
||||
```
|
||||
|
||||
Put Caddy in front of `127.0.0.1:6070` and keep the Warpbox port closed to the public internet.
|
||||
|
||||
## Layout
|
||||
|
||||
- `backend/cmd/warpbox` - main application entry point.
|
||||
@@ -138,6 +206,8 @@ from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your
|
||||
user storage quota, and usage retention.
|
||||
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
|
||||
- `/admin/storage` manages the built-in local file backend and S3-compatible bucket backends.
|
||||
- `/admin/bans` manages manual IP/CIDR bans and optional automatic bans for suspicious probes and
|
||||
repeated login failures. Auto-ban is off by default and configured from the admin UI.
|
||||
- Upload limits now include daily bytes, daily box counts, active box counts, short-window request
|
||||
limits, max expiration days, local storage capacity in GB, and per-user policy overrides.
|
||||
- Uploaded file content, thumbnails, and private box metadata use the selected storage backend.
|
||||
@@ -158,6 +228,8 @@ Warpbox keeps local runtime data under the configured data directory:
|
||||
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
|
||||
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
|
||||
for anonymous uploads and user ID for signed-in uploads.
|
||||
- `data/db/warpbox.bbolt` stores manual bans, automatic ban settings, abuse counters, and malicious
|
||||
path rules.
|
||||
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
|
||||
|
||||
## Static Asset Policy
|
||||
|
||||
63
SECURITY_PROXY.md
Normal file
63
SECURITY_PROXY.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Security Proxy Notes
|
||||
|
||||
Warpbox usually runs behind a reverse proxy such as Caddy. IP-based quotas,
|
||||
manual bans, and automatic bans depend on Warpbox seeing the real client IP.
|
||||
|
||||
## Caddy
|
||||
|
||||
Use this shape when Caddy and Warpbox are on the same host:
|
||||
|
||||
```Caddyfile
|
||||
warpbox.dev {
|
||||
reverse_proxy 127.0.0.1:6070 {
|
||||
header_up X-Forwarded-For {http.request.remote.host}
|
||||
header_up X-Real-IP {http.request.remote.host}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
By default, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` so simple Docker,
|
||||
Podman, and systemd deployments work without extra setup. This is convenient,
|
||||
but it is only safe when the Warpbox port is not directly reachable by the
|
||||
public internet.
|
||||
|
||||
## Trusted Proxies
|
||||
|
||||
For stricter deployments, set `WARPBOX_TRUSTED_PROXIES` to the IPs or CIDR
|
||||
ranges that are allowed to provide forwarded headers:
|
||||
|
||||
```env
|
||||
WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12,10.0.0.0/8
|
||||
```
|
||||
|
||||
When this value is set, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` only
|
||||
if the TCP peer address is inside one of those trusted ranges. Requests coming
|
||||
directly from any other IP ignore forwarded headers and use the socket address.
|
||||
|
||||
Recommended values:
|
||||
|
||||
- Same-host Caddy with systemd: `127.0.0.1,::1`
|
||||
- Docker bridge networks: add the bridge CIDR, often `172.16.0.0/12`
|
||||
- Private reverse-proxy networks: add the exact private CIDR used by the proxy
|
||||
|
||||
## Direct Exposure
|
||||
|
||||
If you expose Warpbox directly without Caddy, either leave
|
||||
`WARPBOX_TRUSTED_PROXIES` empty and ensure clients cannot spoof headers at the
|
||||
network edge, or set it to a value that does not include public clients. Direct
|
||||
public exposure is not recommended; use a reverse proxy for TLS and request
|
||||
normalization.
|
||||
|
||||
## Ban Behavior
|
||||
|
||||
Active bans return:
|
||||
|
||||
```text
|
||||
HTTP/1.1 403 Forbidden
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
forbidden
|
||||
```
|
||||
|
||||
Blocked requests are still written to the JSON logs and appear under
|
||||
`/admin/logs` with `source=ban`.
|
||||
@@ -23,6 +23,7 @@ type Config struct {
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
TrustedProxies []string
|
||||
JobsEnabled bool
|
||||
CleanupEnabled bool
|
||||
CleanupEvery time.Duration
|
||||
@@ -66,6 +67,7 @@ func Load() (Config, error) {
|
||||
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
|
||||
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
|
||||
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
|
||||
TrustedProxies: envCSV("WARPBOX_TRUSTED_PROXIES"),
|
||||
JobsEnabled: envBool("WARPBOX_JOBS_ENABLED", true),
|
||||
CleanupEnabled: envBool("WARPBOX_CLEANUP_ENABLED", true),
|
||||
CleanupEvery: envDuration("WARPBOX_CLEANUP_EVERY", time.Hour),
|
||||
@@ -180,6 +182,21 @@ func envInt(key string, fallback int) int {
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envCSV(key string) []string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
values = append(values, trimmed)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func envMegabytes(key string, fallback float64) int64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
@@ -801,6 +804,182 @@ func TestAdminStorageTestingPageRendersHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
adminToken := createAdminSession(t, app)
|
||||
logDir := filepath.Join(app.cfg.DataDir, "logs")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(logDir, "2026-05-31.log")
|
||||
line := `{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}` + "\n"
|
||||
if err := os.WriteFile(logPath, []byte(line), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
logsRequest := httptest.NewRequest(http.MethodGet, "/admin/logs?q=box123", nil)
|
||||
logsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
logsResponse := httptest.NewRecorder()
|
||||
app.AdminLogs(logsResponse, logsRequest)
|
||||
if logsResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminLogs status = %d, body = %s", logsResponse.Code, logsResponse.Body.String())
|
||||
}
|
||||
logsBody := logsResponse.Body.String()
|
||||
if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") {
|
||||
t.Fatalf("AdminLogs missing expected log entry: %s", logsBody)
|
||||
}
|
||||
|
||||
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
|
||||
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
bansResponse := httptest.NewRecorder()
|
||||
app.AdminBans(bansResponse, bansRequest)
|
||||
if bansResponse.Code != http.StatusOK {
|
||||
t.Fatalf("AdminBans status = %d, body = %s", bansResponse.Code, bansResponse.Body.String())
|
||||
}
|
||||
if !strings.Contains(bansResponse.Body.String(), "Manual ban") || !strings.Contains(bansResponse.Body.String(), "Auto-ban settings") {
|
||||
t.Fatalf("AdminBans missing ban controls: %s", bansResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanCreateAndUnbanIPBan(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
adminToken := createAdminSession(t, app)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Format("2006-01-02T15:04")
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/bans", strings.NewReader("target=203.0.113.90&reason=test&expires_at="+expiresAt+"&csrf_token=test-csrf"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminCreateBan(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminCreateBan status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
records, err := app.banService.ListBans()
|
||||
if err != nil {
|
||||
t.Fatalf("ListBans returned error: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].Normalized != "203.0.113.90" {
|
||||
t.Fatalf("records = %+v", records)
|
||||
}
|
||||
|
||||
unbanRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/"+records[0].ID+"/unban", strings.NewReader("csrf_token=test-csrf"))
|
||||
unbanRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
unbanRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
unbanRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
unbanRequest.SetPathValue("banID", records[0].ID)
|
||||
unbanResponse := httptest.NewRecorder()
|
||||
app.AdminUnban(unbanResponse, unbanRequest)
|
||||
if unbanResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminUnban status = %d, body = %s", unbanResponse.Code, unbanResponse.Body.String())
|
||||
}
|
||||
if _, ok, err := app.banService.Match("203.0.113.90", time.Now().UTC()); err != nil || ok {
|
||||
t.Fatalf("unbanned Match = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCanUpdateBanSettingsAndRules(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
adminToken := createAdminSession(t, app)
|
||||
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/settings", strings.NewReader("auto_ban_enabled=on&auto_ban_duration_hours=48&abuse_window_hours=12&malicious_path_threshold=2&admin_login_failure_threshold=4&user_login_failure_threshold=5&csrf_token=test-csrf"))
|
||||
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
settingsResponse := httptest.NewRecorder()
|
||||
app.AdminBanSettingsPost(settingsResponse, settingsRequest)
|
||||
if settingsResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminBanSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
|
||||
}
|
||||
settings, err := app.banService.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
if !settings.AutoBanEnabled || settings.AutoBanDurationHours != 48 || settings.MaliciousPathThreshold != 2 {
|
||||
t.Fatalf("settings = %+v", settings)
|
||||
}
|
||||
|
||||
rulesRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/rules", strings.NewReader("patterns=%2Fcustom-one%0A%2Fcustom-two&csrf_token=test-csrf"))
|
||||
rulesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rulesRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
rulesRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
rulesResponse := httptest.NewRecorder()
|
||||
app.AdminBanRulesPost(rulesResponse, rulesRequest)
|
||||
if rulesResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminBanRulesPost status = %d, body = %s", rulesResponse.Code, rulesResponse.Body.String())
|
||||
}
|
||||
if pattern, err := app.banService.MaliciousPattern("/x/custom-two"); err != nil || pattern != "/custom-two" {
|
||||
t.Fatalf("MaliciousPattern = %q, %v", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBootstrapUser returned error: %v", err)
|
||||
}
|
||||
settings, err := app.banService.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.UserLoginFailureThreshold = 2
|
||||
if err := app.banService.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("email=admin@example.test&password=wrong"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.RemoteAddr = "203.0.113.91:1234"
|
||||
response := httptest.NewRecorder()
|
||||
app.LoginPost(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("LoginPost status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
if _, ok, err := app.banService.Match("203.0.113.91", time.Now().UTC()); err != nil || !ok {
|
||||
t.Fatalf("Match after login failures = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := app.banService.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.AdminLoginFailureThreshold = 2
|
||||
if err := app.banService.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
app.cfg.AdminToken = "correct-token"
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("token=wrong"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.RemoteAddr = "203.0.113.92:1234"
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminLoginPost(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("AdminLoginPost status = %d", response.Code)
|
||||
}
|
||||
}
|
||||
if _, ok, err := app.banService.Match("203.0.113.92", time.Now().UTC()); err != nil || !ok {
|
||||
t.Fatalf("Match after admin login failures = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
|
||||
t.Helper()
|
||||
user, err := app.authService.UserByID(userID)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -8,8 +9,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
@@ -29,6 +34,8 @@ type adminPageData struct {
|
||||
StorageForm adminStorageFormView
|
||||
StorageTest adminStorageTestView
|
||||
StorageTypes []adminStorageProviderView
|
||||
Logs adminLogsView
|
||||
Bans adminBansView
|
||||
Section string
|
||||
PageTitle string
|
||||
LastInviteURL string
|
||||
@@ -36,6 +43,57 @@ type adminPageData struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminLogsView struct {
|
||||
Entries []adminLogEntry
|
||||
Dates []string
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
TotalShown int
|
||||
}
|
||||
|
||||
type adminLogEntry struct {
|
||||
Date string
|
||||
Time string
|
||||
Source string
|
||||
Severity string
|
||||
Code string
|
||||
Message string
|
||||
Method string
|
||||
Path string
|
||||
Status string
|
||||
IP string
|
||||
UserID string
|
||||
Details string
|
||||
}
|
||||
|
||||
type adminBansView struct {
|
||||
Bans []adminBanView
|
||||
Rules []services.BanRule
|
||||
Settings services.BanSettings
|
||||
Query string
|
||||
Status string
|
||||
ActiveCount int
|
||||
ExpiredCount int
|
||||
UnbannedCount int
|
||||
RulePatterns string
|
||||
Notice string
|
||||
Error string
|
||||
}
|
||||
|
||||
type adminBanView struct {
|
||||
ID string
|
||||
Target string
|
||||
Reason string
|
||||
Source string
|
||||
Status string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
LastMatched string
|
||||
}
|
||||
|
||||
type adminStorageFormView struct {
|
||||
Mode string
|
||||
Provider string
|
||||
@@ -144,7 +202,8 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken {
|
||||
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301)
|
||||
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301, "ip", uploadClientIP(r))
|
||||
a.recordLoginAbuse(r, services.AbuseKindAdminLogin, "admin token login failed")
|
||||
a.renderAdminLogin(w, r, http.StatusUnauthorized, "Invalid admin token.")
|
||||
return
|
||||
}
|
||||
@@ -158,7 +217,7 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
Secure: r.TLS != nil,
|
||||
Expires: time.Now().Add(12 * time.Hour),
|
||||
})
|
||||
a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301)
|
||||
a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -166,6 +225,7 @@ func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin logout", "source", "admin", "severity", "user_activity", "code", 2303, "ip", uploadClientIP(r))
|
||||
a.clearUserSessionCookie(w)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: adminCookieName,
|
||||
@@ -439,9 +499,145 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin settings updated", "source", "admin", "severity", "user_activity", "code", 2310, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
view, err := a.adminLogsView(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load logs", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin viewed logs", "source", "admin", "severity", "user_activity", "code", 2350, "ip", uploadClientIP(r), "date_filter", view.Date)
|
||||
a.renderPage(w, r, http.StatusOK, "admin_logs.html", web.PageData{
|
||||
Title: "Admin logs",
|
||||
Description: "Browse Warpbox JSON logs.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Section: "logs",
|
||||
PageTitle: "Logs",
|
||||
Logs: view,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminBans(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
view, err := a.adminBansView(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load bans", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin viewed bans", "source", "admin", "severity", "user_activity", "code", 2351, "ip", uploadClientIP(r))
|
||||
a.renderPage(w, r, http.StatusOK, "admin_bans.html", web.PageData{
|
||||
Title: "Admin bans",
|
||||
Description: "IP ban controls.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Section: "bans",
|
||||
PageTitle: "Bans",
|
||||
Bans: view,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminCreateBan(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Unable to read ban form."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.ParseInLocation("2006-01-02T15:04", r.FormValue("expires_at"), time.Local)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Expiration date and time is invalid."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
createdBy := "admin"
|
||||
if user, ok := a.currentUser(r); ok {
|
||||
createdBy = user.ID
|
||||
}
|
||||
ban, err := a.banService.CreateManualBan(r.FormValue("target"), r.FormValue("reason"), createdBy, expiresAt.UTC())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("manual ban created", "source", "admin", "severity", "user_activity", "code", 2360, "ip", uploadClientIP(r), "ban_id", ban.ID, "target", ban.Normalized)
|
||||
http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Ban created."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminUnban(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := a.banService.Unban(r.PathValue("banID"), time.Now().UTC()); err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("ban removed", "source", "admin", "severity", "user_activity", "code", 2361, "ip", uploadClientIP(r), "ban_id", r.PathValue("banID"))
|
||||
http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Ban removed."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminBanSettingsPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Unable to read settings form."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
settings := services.BanSettings{
|
||||
AutoBanEnabled: r.FormValue("auto_ban_enabled") == "on",
|
||||
AutoBanDurationHours: parsePositiveInt(r.FormValue("auto_ban_duration_hours")),
|
||||
MaliciousPathThreshold: parsePositiveInt(r.FormValue("malicious_path_threshold")),
|
||||
AdminLoginFailureThreshold: parsePositiveInt(r.FormValue("admin_login_failure_threshold")),
|
||||
UserLoginFailureThreshold: parsePositiveInt(r.FormValue("user_login_failure_threshold")),
|
||||
AbuseWindowHours: parsePositiveInt(r.FormValue("abuse_window_hours")),
|
||||
}
|
||||
if err := a.banService.UpdateSettings(settings); err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("ban settings updated", "source", "admin", "severity", "user_activity", "code", 2362, "ip", uploadClientIP(r), "auto_ban_enabled", settings.AutoBanEnabled)
|
||||
http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Ban settings saved."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminBanRulesPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Unable to read rules form."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
patterns := splitRulePatterns(r.FormValue("patterns"))
|
||||
if err := a.banService.SaveRules(patterns, time.Now().UTC()); err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("ban rules updated", "source", "admin", "severity", "user_activity", "code", 2363, "ip", uploadClientIP(r), "rules", len(patterns))
|
||||
http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Malicious path rules saved."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminBanRuleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := a.banService.DeleteRule(r.PathValue("ruleID")); err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("ban rule deleted", "source", "admin", "severity", "user_activity", "code", 2364, "ip", uploadClientIP(r), "rule_id", r.PathValue("ruleID"))
|
||||
http.Redirect(w, r, "/admin/bans?notice="+url.QueryEscape("Rule deleted."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
@@ -625,6 +821,7 @@ func (a *App) AdminCreateStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/storage/new/"+provider+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("storage backend created", "source", "admin", "severity", "user_activity", "code", 2320, "ip", uploadClientIP(r), "provider", provider)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend added."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -642,6 +839,7 @@ func (a *App) AdminEditStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("storage backend updated", "source", "admin", "severity", "user_activity", "code", 2321, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID"), "provider", provider)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape("Storage backend updated."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -654,9 +852,11 @@ func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) {
|
||||
next = "/admin/storage/" + r.PathValue("backendID") + "/tests"
|
||||
}
|
||||
if _, err := a.uploadService.Storage().TestBackend(r.PathValue("backendID")); err != nil {
|
||||
a.logger.Warn("storage connection test failed", "source", "admin", "severity", "warn", "code", 4320, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID"), "error", err.Error())
|
||||
http.Redirect(w, r, next+"?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("storage connection test passed", "source", "admin", "severity", "user_activity", "code", 2322, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID"))
|
||||
http.Redirect(w, r, next+"?notice="+url.QueryEscape("Storage connection test passed."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -679,6 +879,7 @@ func (a *App) AdminStartStorageSpeedTest(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
go a.uploadService.Storage().RunSpeedTest(context.Background(), test.ID)
|
||||
a.logger.Info("storage speed test started", "source", "admin", "severity", "user_activity", "code", 2323, "ip", uploadClientIP(r), "backend_id", r.PathValue("backendID"), "test_id", test.ID, "mode", test.Mode)
|
||||
http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?notice="+url.QueryEscape("Storage speed test started in the background."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -692,6 +893,7 @@ func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("storage backend disabled", "source", "admin", "severity", "user_activity", "code", 2324, "ip", uploadClientIP(r), "backend_id", id)
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -705,6 +907,7 @@ func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("storage backend deleted", "source", "admin", "severity", "user_activity", "code", 2325, "ip", uploadClientIP(r), "backend_id", id)
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -714,9 +917,11 @@ func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cleaned, err := jobs.RunCleanupNow(a.uploadService, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Warn("admin cleanup run failed", "source", "admin", "severity", "warn", "code", 4340, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin ran cleanup", "source", "admin", "severity", "user_activity", "code", 2340, "ip", uploadClientIP(r), "cleaned", cleaned)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(fmt.Sprintf("Cleanup finished. Removed %d unavailable boxes.", cleaned)), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -726,10 +931,12 @@ func (a *App) AdminRunStorageThumbnails(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
result, err := jobs.RunThumbnailsNow(a.uploadService, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Warn("admin thumbnail run failed", "source", "admin", "severity", "warn", "code", 4341, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("Thumbnail pass finished. Scanned %d files, generated %d, failed %d.", result.Scanned, result.Generated, result.Failed)
|
||||
a.logger.Info("admin ran thumbnail generation", "source", "admin", "severity", "user_activity", "code", 2341, "ip", uploadClientIP(r), "scanned", result.Scanned, "generated", result.Generated, "failed", result.Failed)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -739,6 +946,7 @@ func (a *App) AdminVerifyStorageBackends(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
configs, err := a.uploadService.Storage().ListBackendConfigs()
|
||||
if err != nil {
|
||||
a.logger.Warn("admin storage verification failed", "source", "admin", "severity", "warn", "code", 4342, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -755,6 +963,7 @@ func (a *App) AdminVerifyStorageBackends(w http.ResponseWriter, r *http.Request)
|
||||
passed++
|
||||
}
|
||||
message := fmt.Sprintf("Storage verification finished. %d passed, %d failed.", passed, failed)
|
||||
a.logger.Info("admin verified storage backends", "source", "admin", "severity", "user_activity", "code", 2342, "ip", uploadClientIP(r), "passed", passed, "failed", failed)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(message), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -779,6 +988,7 @@ func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to update quota", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin updated user quota", "source", "admin", "severity", "user_activity", "code", 2330, "ip", uploadClientIP(r), "user_id", r.PathValue("userID"))
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -810,6 +1020,7 @@ func (a *App) AdminUpdateUserPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin updated user policy", "source", "admin", "severity", "user_activity", "code", 2331, "ip", uploadClientIP(r), "user_id", r.PathValue("userID"))
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -848,6 +1059,7 @@ func (a *App) AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin updated user", "source", "admin", "severity", "user_activity", "code", 2332, "ip", uploadClientIP(r), "user_id", r.PathValue("userID"))
|
||||
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -869,6 +1081,7 @@ func (a *App) AdminUpdateUserStorage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to update user storage", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin updated user storage", "source", "admin", "severity", "user_activity", "code", 2333, "ip", uploadClientIP(r), "user_id", r.PathValue("userID"), "backend_id", r.FormValue("storage_backend_id"))
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -886,7 +1099,7 @@ func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID)
|
||||
a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID, "ip", uploadClientIP(r), "email", r.FormValue("email"), "role", r.FormValue("role"))
|
||||
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -899,6 +1112,7 @@ func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to update user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin changed user disabled state", "source", "admin", "severity", "user_activity", "code", 2334, "ip", uploadClientIP(r), "user_id", r.PathValue("userID"), "disabled", disabled)
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -912,6 +1126,7 @@ func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to create reset link", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin generated password reset", "source", "admin", "severity", "user_activity", "code", 2335, "ip", uploadClientIP(r), "admin_id", admin.ID, "user_id", r.PathValue("userID"))
|
||||
if r.URL.Query().Get("next") == "edit" {
|
||||
http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
|
||||
return
|
||||
@@ -930,6 +1145,7 @@ func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to delete box", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.logger.Info("admin deleted box", "source", "admin", "severity", "user_activity", "code", 2304, "ip", uploadClientIP(r), "box_id", boxID)
|
||||
http.Redirect(w, r, "/admin/files", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -1101,6 +1317,284 @@ func formatMB(value float64) string {
|
||||
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
|
||||
}
|
||||
|
||||
func (a *App) adminBansView(r *http.Request) (adminBansView, error) {
|
||||
settings, err := a.banService.Settings()
|
||||
if err != nil {
|
||||
return adminBansView{}, err
|
||||
}
|
||||
records, err := a.banService.ListBans()
|
||||
if err != nil {
|
||||
return adminBansView{}, err
|
||||
}
|
||||
rules, err := a.banService.ListRules()
|
||||
if err != nil {
|
||||
return adminBansView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||
statusFilter := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
rows := []adminBanView{}
|
||||
active, expired, unbanned := 0, 0, 0
|
||||
for _, record := range records {
|
||||
status := record.Status(now)
|
||||
switch status {
|
||||
case "active":
|
||||
active++
|
||||
case "expired":
|
||||
expired++
|
||||
case "unbanned":
|
||||
unbanned++
|
||||
}
|
||||
if statusFilter != "" && status != statusFilter {
|
||||
continue
|
||||
}
|
||||
search := strings.ToLower(strings.Join([]string{record.Target, record.Normalized, record.Reason, record.Source, status}, " "))
|
||||
if query != "" && !strings.Contains(search, query) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, adminBanView{
|
||||
ID: record.ID,
|
||||
Target: record.Normalized,
|
||||
Reason: record.Reason,
|
||||
Source: record.Source,
|
||||
Status: status,
|
||||
CreatedAt: record.CreatedAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: record.ExpiresAt.Format("Jan 2 15:04"),
|
||||
LastMatched: formatOptionalTime(record.LastMatchedAt),
|
||||
})
|
||||
}
|
||||
return adminBansView{
|
||||
Bans: rows,
|
||||
Rules: rules,
|
||||
Settings: settings,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Status: statusFilter,
|
||||
ActiveCount: active,
|
||||
ExpiredCount: expired,
|
||||
UnbannedCount: unbanned,
|
||||
RulePatterns: joinRulePatterns(rules),
|
||||
Notice: r.URL.Query().Get("notice"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func formatOptionalTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return "Never"
|
||||
}
|
||||
return value.Format("Jan 2 15:04")
|
||||
}
|
||||
|
||||
func joinRulePatterns(rules []services.BanRule) string {
|
||||
patterns := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.Enabled {
|
||||
patterns = append(patterns, rule.Pattern)
|
||||
}
|
||||
}
|
||||
return strings.Join(patterns, "\n")
|
||||
}
|
||||
|
||||
func splitRulePatterns(value string) []string {
|
||||
lines := strings.Split(value, "\n")
|
||||
patterns := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
}
|
||||
return patterns
|
||||
}
|
||||
|
||||
func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) {
|
||||
logDir := filepath.Join(a.cfg.DataDir, "logs")
|
||||
dates, err := availableLogDates(logDir)
|
||||
if err != nil {
|
||||
return adminLogsView{}, err
|
||||
}
|
||||
selectedDate := strings.TrimSpace(r.URL.Query().Get("date"))
|
||||
if selectedDate == "" && len(dates) > 0 {
|
||||
selectedDate = dates[0]
|
||||
} else if selectedDate != "" && selectedDate != "all" && !containsString(dates, selectedDate) {
|
||||
selectedDate = ""
|
||||
}
|
||||
severity := strings.TrimSpace(r.URL.Query().Get("severity"))
|
||||
source := strings.TrimSpace(r.URL.Query().Get("source"))
|
||||
query := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||
sortOrder := strings.TrimSpace(r.URL.Query().Get("sort"))
|
||||
if sortOrder != "asc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
|
||||
files := []string{}
|
||||
if selectedDate == "all" {
|
||||
for _, date := range dates {
|
||||
files = append(files, filepath.Join(logDir, date+".log"))
|
||||
}
|
||||
} else if selectedDate != "" {
|
||||
files = append(files, filepath.Join(logDir, selectedDate+".log"))
|
||||
}
|
||||
|
||||
entries := []adminLogEntry{}
|
||||
for _, file := range files {
|
||||
fileEntries, err := readLogEntries(file)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return adminLogsView{}, err
|
||||
}
|
||||
for _, entry := range fileEntries {
|
||||
if severity != "" && entry.Severity != severity {
|
||||
continue
|
||||
}
|
||||
if source != "" && entry.Source != source {
|
||||
continue
|
||||
}
|
||||
if query != "" && !strings.Contains(strings.ToLower(entry.searchText()), query) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
left := entries[i].Date + " " + entries[i].Time
|
||||
right := entries[j].Date + " " + entries[j].Time
|
||||
if sortOrder == "asc" {
|
||||
return left < right
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
if len(entries) > 500 {
|
||||
entries = entries[:500]
|
||||
}
|
||||
return adminLogsView{
|
||||
Entries: entries,
|
||||
Dates: dates,
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
TotalShown: len(entries),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func availableLogDates(logDir string) ([]string, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dates := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
name := strings.TrimSuffix(filepath.Base(match), ".log")
|
||||
if name != "" {
|
||||
dates = append(dates, name)
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(dates)))
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readLogEntries(file string) ([]adminLogEntry, error) {
|
||||
handle, err := os.Open(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer handle.Close()
|
||||
scanner := bufio.NewScanner(handle)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
entries := []adminLogEntry{}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(line, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, logEntryFromMap(raw))
|
||||
}
|
||||
return entries, scanner.Err()
|
||||
}
|
||||
|
||||
func logEntryFromMap(raw map[string]any) adminLogEntry {
|
||||
entry := adminLogEntry{
|
||||
Date: logString(raw, "date"),
|
||||
Time: logString(raw, "time"),
|
||||
Source: logString(raw, "source"),
|
||||
Severity: logString(raw, "severity"),
|
||||
Code: logAnyString(raw["code"]),
|
||||
Message: logString(raw, "log"),
|
||||
Method: logString(raw, "method"),
|
||||
Path: logString(raw, "path"),
|
||||
Status: logAnyString(raw["status"]),
|
||||
IP: firstLogString(raw, "ip", "client_ip", "remote_addr"),
|
||||
UserID: logString(raw, "user_id"),
|
||||
}
|
||||
entry.Details = logDetails(raw)
|
||||
return entry
|
||||
}
|
||||
|
||||
func logDetails(raw map[string]any) string {
|
||||
ignore := map[string]bool{
|
||||
"date": true, "time": true, "source": true, "severity": true, "code": true, "log": true,
|
||||
"method": true, "path": true, "status": true, "ip": true, "client_ip": true, "remote_addr": true, "user_id": true,
|
||||
}
|
||||
details := map[string]any{}
|
||||
for key, value := range raw {
|
||||
if !ignore[key] {
|
||||
details[key] = value
|
||||
}
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (e adminLogEntry) searchText() string {
|
||||
return strings.Join([]string{e.Date, e.Time, e.Source, e.Severity, e.Code, e.Message, e.Method, e.Path, e.Status, e.IP, e.UserID, e.Details}, " ")
|
||||
}
|
||||
|
||||
func logString(raw map[string]any, key string) string {
|
||||
return logAnyString(raw[key])
|
||||
}
|
||||
|
||||
func firstLogString(raw map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := logString(raw, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func logAnyString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case float64:
|
||||
return strconv.FormatFloat(typed, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(typed)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
func adminStorageSpeedTestsJSON(tests []services.StorageSpeedTest) []adminStorageSpeedTestJSON {
|
||||
rows := make([]adminStorageSpeedTestJSON, 0, len(tests))
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -16,10 +16,11 @@ type App struct {
|
||||
uploadService *services.UploadService
|
||||
authService *services.AuthService
|
||||
settingsService *services.SettingsService
|
||||
banService *services.BanService
|
||||
rateLimiter *rateLimiter
|
||||
}
|
||||
|
||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
|
||||
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
@@ -27,6 +28,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo
|
||||
uploadService: uploadService,
|
||||
authService: authService,
|
||||
settingsService: settingsService,
|
||||
banService: banService,
|
||||
rateLimiter: newRateLimiter(),
|
||||
}
|
||||
}
|
||||
@@ -67,6 +69,13 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /admin/users/{userID}/edit", a.AdminEditUser)
|
||||
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
|
||||
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
|
||||
mux.HandleFunc("GET /admin/logs", a.AdminLogs)
|
||||
mux.HandleFunc("GET /admin/bans", a.AdminBans)
|
||||
mux.HandleFunc("POST /admin/bans", a.AdminCreateBan)
|
||||
mux.HandleFunc("POST /admin/bans/{banID}/unban", a.AdminUnban)
|
||||
mux.HandleFunc("POST /admin/bans/settings", a.AdminBanSettingsPost)
|
||||
mux.HandleFunc("POST /admin/bans/rules", a.AdminBanRulesPost)
|
||||
mux.HandleFunc("POST /admin/bans/rules/{ruleID}/delete", a.AdminBanRuleDelete)
|
||||
mux.HandleFunc("GET /admin/storage", a.AdminStorage)
|
||||
mux.HandleFunc("GET /admin/storage/new", a.AdminNewStorage)
|
||||
mux.HandleFunc("GET /admin/storage/new/s3", a.AdminNewStorageProvider)
|
||||
|
||||
@@ -35,6 +35,7 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.rateLimiter.Allow("register:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||
a.logger.Warn("registration rate limited", "source", "auth", "severity", "warn", "code", 4291, "ip", uploadClientIP(r))
|
||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "register", Error: "Too many registration attempts."})
|
||||
return
|
||||
}
|
||||
@@ -44,10 +45,11 @@ func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("bootstrap registration failed", "source", "auth", "severity", "warn", "code", 4400, "ip", uploadClientIP(r), "email", r.FormValue("email"), "error", err.Error())
|
||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
|
||||
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.rateLimiter.Allow("login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) {
|
||||
a.logger.Warn("login rate limited", "source", "auth", "severity", "warn", "code", 4292, "ip", uploadClientIP(r), "email", r.FormValue("email"))
|
||||
a.renderAuth(w, r, http.StatusTooManyRequests, authPageData{Mode: "login", Error: "Too many login attempts."})
|
||||
return
|
||||
}
|
||||
@@ -74,12 +77,13 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
|
||||
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"), "ip", uploadClientIP(r))
|
||||
a.recordLoginAbuse(r, services.AbuseKindUserLogin, "user login failed")
|
||||
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
|
||||
return
|
||||
}
|
||||
a.setUserSessionCookie(w, r, token)
|
||||
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)
|
||||
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -87,6 +91,9 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if user, ok := a.currentUser(r); ok {
|
||||
a.logger.Info("user logout", "source", "auth", "severity", "user_activity", "code", 2405, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
}
|
||||
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
|
||||
_ = a.authService.Logout(cookie.Value)
|
||||
}
|
||||
@@ -107,6 +114,7 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.PathValue("token")
|
||||
invite, err := a.authService.InviteByToken(token)
|
||||
if err != nil {
|
||||
a.logger.Warn("invite accept invalid", "source", "auth", "severity", "warn", "code", 4404, "ip", uploadClientIP(r))
|
||||
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
|
||||
return
|
||||
}
|
||||
@@ -116,10 +124,11 @@ func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
|
||||
if err != nil {
|
||||
a.logger.Warn("invite accept failed", "source", "auth", "severity", "warn", "code", 4405, "ip", uploadClientIP(r), "invite_email", invite.Email, "error", err.Error())
|
||||
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
|
||||
return
|
||||
}
|
||||
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
|
||||
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID, "ip", uploadClientIP(r), "invite_email", invite.Email)
|
||||
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
|
||||
}
|
||||
|
||||
@@ -176,6 +185,8 @@ func (a *App) DeleteUserToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if err := a.authService.DeleteAPIToken(user.ID, r.PathValue("tokenID")); err != nil {
|
||||
a.logger.Warn("api token delete failed", "source", "user_activity", "severity", "warn", "code", 4421, "user_id", user.ID, "error", err.Error())
|
||||
} else {
|
||||
a.logger.Info("api token deleted", "source", "user_activity", "severity", "user_activity", "code", 2421, "user_id", user.ID, "token_id", r.PathValue("tokenID"))
|
||||
}
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
@@ -222,13 +233,16 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
|
||||
a.logger.Warn("password change failed current password", "source", "user_activity", "severity", "warn", "code", 4422, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
|
||||
a.logger.Warn("password change failed", "source", "user_activity", "severity", "warn", "code", 4423, "user_id", user.ID, "error", err.Error())
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("password changed", "source", "user_activity", "severity", "user_activity", "code", 2422, "user_id", user.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
|
||||
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
|
||||
} else {
|
||||
a.logger.Info("collection created", "source", "user_activity", "severity", "user_activity", "code", 2410, "user_id", user.ID, "name", r.FormValue("name"))
|
||||
}
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
@@ -127,9 +129,11 @@ func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
|
||||
a.logger.Warn("owned box rename failed", "source", "user_activity", "severity", "warn", "code", 4411, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box renamed", "source", "user_activity", "severity", "user_activity", "code", 2411, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -144,13 +148,16 @@ func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
collectionID := r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
a.logger.Warn("owned box move invalid collection", "source", "user_activity", "severity", "warn", "code", 4412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
||||
http.Error(w, "collection not found", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
|
||||
a.logger.Warn("owned box move failed", "source", "user_activity", "severity", "warn", "code", 4413, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box moved", "source", "user_activity", "severity", "user_activity", "code", 2412, "user_id", user.ID, "box_id", r.PathValue("boxID"), "collection_id", collectionID)
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -160,9 +167,11 @@ func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
|
||||
a.logger.Warn("owned box delete failed", "source", "user_activity", "severity", "warn", "code", 4414, "user_id", user.ID, "box_id", r.PathValue("boxID"), "error", err.Error())
|
||||
a.handleUserBoxError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.logger.Info("owned box deleted", "source", "user_activity", "severity", "user_activity", "code", 2413, "user_id", user.ID, "box_id", r.PathValue("boxID"))
|
||||
http.Redirect(w, r, "/app", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,10 +53,12 @@ type previewPageData struct {
|
||||
func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("download page missing box", "source", "download", "severity", "warn", "code", 4040, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("download page unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
|
||||
Title: "Download unavailable",
|
||||
Description: "This Warpbox link is no longer available.",
|
||||
@@ -99,6 +101,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
ExpiresLabel: expiresLabel,
|
||||
},
|
||||
})
|
||||
a.logger.Info("download page viewed", "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "ip", uploadClientIP(r), "locked", locked)
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
@@ -136,6 +139,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
DownloadURL: view.DownloadURL,
|
||||
},
|
||||
})
|
||||
a.logger.Info("file preview page viewed", "source", "download", "severity", "user_activity", "code", 2004, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
||||
}
|
||||
|
||||
func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -144,11 +148,13 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
a.logger.Warn("protected file download blocked", "source", "download", "severity", "warn", "code", 4013, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r))
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
||||
a.logger.Info("file content served", "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "attachment", r.URL.Query().Get("inline") != "1")
|
||||
}
|
||||
|
||||
func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -196,7 +202,7 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !a.uploadService.VerifyPassword(box, r.FormValue("password")) {
|
||||
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID)
|
||||
a.logger.Warn("box unlock failed", "source", "user_activity", "severity", "warn", "code", 4011, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -209,23 +215,26 @@ func (a *App) UnlockBox(w http.ResponseWriter, r *http.Request) {
|
||||
Secure: r.TLS != nil,
|
||||
Expires: box.ExpiresAt,
|
||||
})
|
||||
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID)
|
||||
a.logger.Info("box unlocked", "source", "user_activity", "severity", "user_activity", "code", 2002, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, fmt.Sprintf("/d/%s", box.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (services.Box, services.File, bool) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("file request missing box", "source", "download", "severity", "warn", "code", 4041, "box_id", r.PathValue("boxID"), "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("file request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
|
||||
file, err := a.uploadService.FindFile(box, r.PathValue("fileID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("file request missing file", "source", "download", "severity", "warn", "code", 4042, "box_id", box.ID, "file_id", r.PathValue("fileID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, services.File{}, false
|
||||
}
|
||||
@@ -235,6 +244,7 @@ func (a *App) loadFileForRequest(w http.ResponseWriter, r *http.Request) (servic
|
||||
func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box services.Box, file services.File, attachment bool) {
|
||||
object, err := a.uploadService.OpenFileObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
a.logger.Warn("file object missing", "source", "download", "severity", "warn", "code", 4043, "box_id", box.ID, "file_id", file.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -270,14 +280,17 @@ func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
||||
func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("zip request missing box", "source", "download", "severity", "warn", "code", 4044, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.CanDownload(box); err != nil {
|
||||
a.logger.Warn("zip request unavailable", "source", "download", "severity", "warn", "code", statusForDownloadError(err), "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.Error(w, err.Error(), statusForDownloadError(err))
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) {
|
||||
a.logger.Warn("protected zip download blocked", "source", "download", "severity", "warn", "code", 4014, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -293,6 +306,7 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.uploadService.RecordDownload(box.ID); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.logger.Warn("failed to record zip download", "source", "download", "severity", "warn", "code", 4003, "box_id", box.ID, "error", err.Error())
|
||||
}
|
||||
a.logger.Info("zip downloaded", "source", "download", "severity", "user_activity", "code", 2006, "box_id", box.ID, "ip", uploadClientIP(r), "files", len(box.Files))
|
||||
}
|
||||
|
||||
func (a *App) fileView(box services.Box, file services.File) fileView {
|
||||
|
||||
@@ -31,6 +31,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
|
||||
Description: "Delete this anonymous Warpbox upload.",
|
||||
Data: a.managePageData(box, r.PathValue("token")),
|
||||
})
|
||||
a.logger.Info("anonymous manage page viewed", "source", "anonymous-delete", "severity", "user_activity", "code", 2102, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
}
|
||||
|
||||
func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -40,10 +41,11 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := a.uploadService.DeleteBoxWithToken(box.ID, r.PathValue("token")); err != nil {
|
||||
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "error", err.Error())
|
||||
a.logger.Warn("anonymous delete failed", "source", "anonymous-delete", "severity", "warn", "code", 4102, "box_id", box.ID, "ip", uploadClientIP(r), "error", err.Error())
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.logger.Info("anonymous box deleted", "source", "anonymous-delete", "severity", "user_activity", "code", 2103, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.Redirect(w, r, "/d/"+box.ID+"/deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -58,10 +60,12 @@ func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) loadManagedBox(w http.ResponseWriter, r *http.Request) (services.Box, bool) {
|
||||
box, err := a.uploadService.GetBox(r.PathValue("boxID"))
|
||||
if err != nil {
|
||||
a.logger.Warn("anonymous manage missing box", "source", "anonymous-delete", "severity", "warn", "code", 4103, "box_id", r.PathValue("boxID"), "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, false
|
||||
}
|
||||
if !a.uploadService.VerifyDeleteToken(box, r.PathValue("token")) {
|
||||
a.logger.Warn("anonymous manage invalid token", "source", "anonymous-delete", "severity", "warn", "code", 4104, "box_id", box.ID, "ip", uploadClientIP(r))
|
||||
http.NotFound(w, r)
|
||||
return services.Box{}, false
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
const csrfCookieName = "warpbox_csrf"
|
||||
@@ -76,3 +78,29 @@ func randomToken(byteCount int) string {
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func (a *App) recordLoginAbuse(r *http.Request, kind, detail string) {
|
||||
if a.banService == nil {
|
||||
return
|
||||
}
|
||||
settings, err := a.banService.Settings()
|
||||
if err != nil || !settings.AutoBanEnabled {
|
||||
return
|
||||
}
|
||||
threshold := settings.UserLoginFailureThreshold
|
||||
if kind == services.AbuseKindAdminLogin {
|
||||
threshold = settings.AdminLoginFailureThreshold
|
||||
}
|
||||
ip := uploadClientIP(r)
|
||||
result, err := a.banService.RecordAbuse(ip, kind, detail, threshold, time.Now().UTC())
|
||||
if err != nil {
|
||||
a.logger.Error("login abuse event failed", "source", "ban", "severity", "error", "code", 5004, "ip", ip, "kind", kind, "error", err.Error())
|
||||
return
|
||||
}
|
||||
if result.Enabled {
|
||||
a.logger.Warn("login abuse recorded", "source", "ban", "severity", "warn", "code", 4304, "ip", ip, "kind", kind, "count", result.Event.Count)
|
||||
}
|
||||
if result.Triggered {
|
||||
a.logger.Warn("ip auto-banned for login abuse", "source", "ban", "severity", "warn", "code", 4305, "ip", ip, "kind", kind, "ban_id", result.Ban.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
user, loggedIn, authErr := a.currentUserWithAuthError(r)
|
||||
if authErr != nil {
|
||||
a.logger.Warn("upload rejected invalid bearer token", "source", "user-upload", "severity", "warn", "code", 4010, "ip", uploadClientIP(r), "user_agent", r.UserAgent())
|
||||
helpers.WriteJSONError(w, http.StatusUnauthorized, "invalid bearer token")
|
||||
return
|
||||
}
|
||||
@@ -29,12 +30,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !loggedIn && !settings.AnonymousUploadsEnabled {
|
||||
a.logger.Warn("anonymous upload rejected disabled", "source", "user-upload", "severity", "warn", "code", 4012, "ip", uploadClientIP(r))
|
||||
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
|
||||
return
|
||||
}
|
||||
effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn)
|
||||
rateKey := uploadRateKey(r, user, loggedIn)
|
||||
if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) {
|
||||
a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID)
|
||||
helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down")
|
||||
return
|
||||
}
|
||||
@@ -49,6 +52,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
parseLimit = 32 << 20
|
||||
}
|
||||
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||
a.logger.Warn("upload form parse failed", "source", "user-upload", "severity", "warn", "code", 4000, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||
return
|
||||
}
|
||||
@@ -61,12 +65,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
ownerID = user.ID
|
||||
collectionID = r.FormValue("collection_id")
|
||||
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
|
||||
a.logger.Warn("upload rejected invalid collection", "source", "user-upload", "severity", "warn", "code", 4030, "user_id", user.ID, "collection_id", collectionID)
|
||||
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
if !isAdminUpload {
|
||||
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, effectivePolicy, files, totalBytes); message != "" {
|
||||
a.logger.Warn("upload rejected by policy", "source", "quota", "severity", "warn", "code", status, "ip", uploadClientIP(r), "user_id", user.ID, "message", message, "bytes", totalBytes, "files", len(files))
|
||||
helpers.WriteJSONError(w, status, message)
|
||||
return
|
||||
}
|
||||
@@ -76,11 +82,13 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
maxDays = min(7, effectivePolicy.MaxDays)
|
||||
}
|
||||
if !isAdminUpload && maxDays > effectivePolicy.MaxDays {
|
||||
a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
expiresMinutes := parseInt(r.FormValue("expires_minutes"))
|
||||
if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 {
|
||||
a.logger.Warn("upload rejected expiration minutes", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays)
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays))
|
||||
return
|
||||
}
|
||||
@@ -97,7 +105,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
StorageBackendID: effectivePolicy.StorageBackendID,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
|
||||
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "ip", uploadClientIP(r), "user_id", user.ID, "error", err.Error())
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -110,6 +118,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
|
||||
a.logger.Info("upload response sent", "source", "user-upload", "severity", "user_activity", "code", 2001, "ip", uploadClientIP(r), "user_id", user.ID, "box_id", result.BoxID, "files", len(files), "bytes", totalBytes, "admin", isAdminUpload)
|
||||
|
||||
if wantsJSON(r) {
|
||||
helpers.WriteJSON(w, http.StatusCreated, result)
|
||||
@@ -235,7 +244,10 @@ func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fall
|
||||
}
|
||||
|
||||
func uploadClientIP(r *http.Request) string {
|
||||
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
if ip, ok := services.ClientIPFromContext(r); ok {
|
||||
return ip
|
||||
}
|
||||
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
|
||||
}
|
||||
|
||||
func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
|
||||
|
||||
@@ -213,7 +213,12 @@ func newTestApp(t *testing.T) (*App, func()) {
|
||||
service.Close()
|
||||
t.Fatalf("NewSettingsService returned error: %v", err)
|
||||
}
|
||||
return NewApp(cfg, logger, renderer, service, authService, settingsService), func() {
|
||||
banService, err := services.NewBanService(service.DB())
|
||||
if err != nil {
|
||||
service.Close()
|
||||
t.Fatalf("NewBanService returned error: %v", err)
|
||||
}
|
||||
return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() {
|
||||
if err := service.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
uploadService.Close()
|
||||
return nil, err
|
||||
}
|
||||
stopJobs := jobs.StartAll(cfg, logger, uploadService)
|
||||
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService)
|
||||
banService, err := services.NewBanService(uploadService.DB())
|
||||
if err != nil {
|
||||
uploadService.Close()
|
||||
return nil, err
|
||||
}
|
||||
stopJobs := jobs.StartAll(cfg, logger, uploadService, banService)
|
||||
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService)
|
||||
|
||||
router := http.NewServeMux()
|
||||
app.RegisterRoutes(router)
|
||||
@@ -45,6 +50,7 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
middleware.SecurityHeaders,
|
||||
middleware.Gzip,
|
||||
middleware.Logger(logger),
|
||||
middleware.Bans(logger, banService, cfg.TrustedProxies),
|
||||
)
|
||||
|
||||
server := &http.Server{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
|
||||
func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) job {
|
||||
return job{
|
||||
name: "cleanup",
|
||||
enabled: cfg.CleanupEnabled,
|
||||
@@ -22,6 +22,16 @@ func newCleanupJob(cfg config.Config, logger *slog.Logger, uploadService *servic
|
||||
if cleaned > 0 {
|
||||
logger.Info("cleanup job complete", "source", "housekeeping", "severity", "user_activity", "code", 2202, "cleaned", cleaned)
|
||||
}
|
||||
if banService != nil {
|
||||
cleanedEvents, err := banService.CleanupAbuseEvents(time.Now().UTC())
|
||||
if err != nil {
|
||||
logger.Warn("ban evidence cleanup failed", "source", "housekeeping", "severity", "warn", "code", 4203, "error", err.Error())
|
||||
return
|
||||
}
|
||||
if cleanedEvents > 0 {
|
||||
logger.Info("ban evidence cleaned", "source", "housekeeping", "severity", "user_activity", "code", 2203, "cleaned", cleanedEvents)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ type job struct {
|
||||
run func()
|
||||
}
|
||||
|
||||
func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) func() {
|
||||
func StartAll(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService, banService *services.BanService) func() {
|
||||
if !cfg.JobsEnabled {
|
||||
logger.Info("background jobs disabled", "source", "jobs", "severity", "dev")
|
||||
return func() {}
|
||||
}
|
||||
|
||||
stops := []func(){
|
||||
start(newCleanupJob(cfg, logger, uploadService), logger),
|
||||
start(newCleanupJob(cfg, logger, uploadService, banService), logger),
|
||||
start(newThumbnailsJob(cfg, logger, uploadService), logger),
|
||||
}
|
||||
|
||||
|
||||
65
backend/libs/middleware/bans.go
Normal file
65
backend/libs/middleware/bans.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||
r = services.WithClientIP(r, ip)
|
||||
now := time.Now().UTC()
|
||||
|
||||
if bans != nil {
|
||||
if matched, ok, err := bans.Match(ip, now); err != nil {
|
||||
logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error())
|
||||
} else if ok {
|
||||
logger.Warn("banned request blocked", "source", "ban", "severity", "warn", "code", 4030, "ip", ip, "ban_id", matched.Ban.ID, "target", matched.Ban.Normalized, "path", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("forbidden\n"))
|
||||
return
|
||||
}
|
||||
|
||||
if pattern, err := bans.MaliciousPattern(r.URL.Path); err != nil {
|
||||
logger.Error("malicious path check failed", "source", "ban", "severity", "error", "code", 5002, "ip", ip, "error", err.Error())
|
||||
} else if pattern != "" {
|
||||
if result, err := bans.RecordAbuse(ip, services.AbuseKindMaliciousPath, r.URL.Path, banThreshold(bans, services.AbuseKindMaliciousPath), now); err != nil {
|
||||
logger.Error("malicious path event failed", "source", "ban", "severity", "error", "code", 5003, "ip", ip, "path", r.URL.Path, "error", err.Error())
|
||||
} else if result.Enabled {
|
||||
logger.Warn("malicious path requested", "source", "ban", "severity", "warn", "code", 4302, "ip", ip, "path", r.URL.Path, "pattern", pattern, "count", result.Event.Count)
|
||||
if result.Triggered {
|
||||
logger.Warn("ip auto-banned for malicious path", "source", "ban", "severity", "warn", "code", 4303, "ip", ip, "ban_id", result.Ban.ID, "path", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("forbidden\n"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func banThreshold(bans *services.BanService, kind string) int {
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
switch kind {
|
||||
case services.AbuseKindAdminLogin:
|
||||
return settings.AdminLoginFailureThreshold
|
||||
case services.AbuseKindUserLogin:
|
||||
return settings.UserLoginFailureThreshold
|
||||
default:
|
||||
return settings.MaliciousPathThreshold
|
||||
}
|
||||
}
|
||||
99
backend/libs/middleware/bans_test.go
Normal file
99
backend/libs/middleware/bans_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func TestBansMiddlewareBlocksActiveBan(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
if _, err := bans.CreateManualBan("203.0.113.20", "test", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("CreateManualBan returned error: %v", err)
|
||||
}
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:6070"
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.20")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusForbidden || response.Body.String() != "forbidden\n" {
|
||||
t.Fatalf("blocked response = %d %q", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareAllowsNonBannedIP(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
called := false
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.RemoteAddr = "203.0.113.21:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if !called || response.Code != http.StatusOK {
|
||||
t.Fatalf("allowed response = called %v code %d", called, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareAutoBansMaliciousPaths(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.MaliciousPathThreshold = 3
|
||||
if err := bans.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, nil))
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||
request.RemoteAddr = "203.0.113.22:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if i < 2 && response.Code == http.StatusForbidden {
|
||||
t.Fatalf("request %d blocked before threshold", i+1)
|
||||
}
|
||||
if i == 2 && response.Code != http.StatusForbidden {
|
||||
t.Fatalf("request 3 status = %d, want forbidden", response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newMiddlewareBanService(t *testing.T) *services.BanService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
upload, err := services.NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewUploadService returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := upload.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
})
|
||||
bans, err := services.NewBanService(upload.DB())
|
||||
if err != nil {
|
||||
t.Fatalf("NewBanService returned error: %v", err)
|
||||
}
|
||||
return bans
|
||||
}
|
||||
545
backend/libs/services/bans.go
Normal file
545
backend/libs/services/bans.go
Normal file
@@ -0,0 +1,545 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var (
|
||||
bansBucket = []byte("bans")
|
||||
abuseEventsBucket = []byte("abuse_events")
|
||||
banRulesBucket = []byte("ban_rules")
|
||||
banSettingsBucket = []byte("ban_settings")
|
||||
banSettingsKey = []byte("settings")
|
||||
defaultBanRulesSeed = []byte("default_rules_seeded")
|
||||
)
|
||||
|
||||
const (
|
||||
BanSourceManual = "manual"
|
||||
BanSourceAuto = "auto"
|
||||
|
||||
AbuseKindMaliciousPath = "malicious_path"
|
||||
AbuseKindAdminLogin = "admin_login_failure"
|
||||
AbuseKindUserLogin = "user_login_failure"
|
||||
)
|
||||
|
||||
var defaultMaliciousPathRules = []string{
|
||||
"/wp-admin",
|
||||
"/.env",
|
||||
"/.git/config",
|
||||
"/phpmyadmin",
|
||||
"/wp-login.php",
|
||||
"/xmlrpc.php",
|
||||
"/config.php",
|
||||
"/vendor/phpunit",
|
||||
".env",
|
||||
"backup",
|
||||
"dump.sql",
|
||||
}
|
||||
|
||||
var ErrBanNotFound = errors.New("ban not found")
|
||||
|
||||
type BanService struct {
|
||||
db *bbolt.DB
|
||||
}
|
||||
|
||||
type BanSettings struct {
|
||||
AutoBanEnabled bool `json:"autoBanEnabled"`
|
||||
AutoBanDurationHours int `json:"autoBanDurationHours"`
|
||||
MaliciousPathThreshold int `json:"maliciousPathThreshold"`
|
||||
AdminLoginFailureThreshold int `json:"adminLoginFailureThreshold"`
|
||||
UserLoginFailureThreshold int `json:"userLoginFailureThreshold"`
|
||||
AbuseWindowHours int `json:"abuseWindowHours"`
|
||||
}
|
||||
|
||||
type BanRecord struct {
|
||||
ID string `json:"id"`
|
||||
Target string `json:"target"`
|
||||
Normalized string `json:"normalized"`
|
||||
Reason string `json:"reason"`
|
||||
Source string `json:"source"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
UnbannedAt *time.Time `json:"unbannedAt,omitempty"`
|
||||
LastMatchedAt *time.Time `json:"lastMatchedAt,omitempty"`
|
||||
}
|
||||
|
||||
type BanRule struct {
|
||||
ID string `json:"id"`
|
||||
Pattern string `json:"pattern"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AbuseEvent struct {
|
||||
Key string `json:"key"`
|
||||
IP string `json:"ip"`
|
||||
Kind string `json:"kind"`
|
||||
Count int `json:"count"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type MatchedBan struct {
|
||||
Ban BanRecord
|
||||
IP string
|
||||
}
|
||||
|
||||
type AbuseResult struct {
|
||||
Event AbuseEvent
|
||||
Ban BanRecord
|
||||
Triggered bool
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NewBanService(db *bbolt.DB) (*BanService, error) {
|
||||
service := &BanService{db: db}
|
||||
err := db.Update(func(tx *bbolt.Tx) error {
|
||||
for _, bucket := range [][]byte{bansBucket, abuseEventsBucket, banRulesBucket, banSettingsBucket} {
|
||||
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if tx.Bucket(banSettingsBucket).Get(banSettingsKey) == nil {
|
||||
data, err := json.Marshal(DefaultBanSettings())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Bucket(banSettingsBucket).Put(banSettingsKey, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rules := tx.Bucket(banRulesBucket)
|
||||
if rules.Get(defaultBanRulesSeed) == nil {
|
||||
now := time.Now().UTC()
|
||||
for _, pattern := range defaultMaliciousPathRules {
|
||||
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
|
||||
data, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rules.Put([]byte(rule.ID), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rules.Put(defaultBanRulesSeed, []byte("1")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return service, err
|
||||
}
|
||||
|
||||
func DefaultBanSettings() BanSettings {
|
||||
return BanSettings{
|
||||
AutoBanEnabled: false,
|
||||
AutoBanDurationHours: 24,
|
||||
MaliciousPathThreshold: 3,
|
||||
AdminLoginFailureThreshold: 10,
|
||||
UserLoginFailureThreshold: 30,
|
||||
AbuseWindowHours: 24,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BanService) Settings() (BanSettings, error) {
|
||||
settings := DefaultBanSettings()
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
data := tx.Bucket(banSettingsBucket).Get(banSettingsKey)
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
settings = withBanSettingDefaults(settings)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return BanSettings{}, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *BanService) UpdateSettings(settings BanSettings) error {
|
||||
settings = withBanSettingDefaults(settings)
|
||||
if settings.AutoBanDurationHours <= 0 || settings.MaliciousPathThreshold <= 0 ||
|
||||
settings.AdminLoginFailureThreshold <= 0 || settings.UserLoginFailureThreshold <= 0 ||
|
||||
settings.AbuseWindowHours <= 0 {
|
||||
return fmt.Errorf("ban settings must be positive")
|
||||
}
|
||||
data, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(banSettingsBucket).Put(banSettingsKey, data)
|
||||
})
|
||||
}
|
||||
|
||||
func withBanSettingDefaults(settings BanSettings) BanSettings {
|
||||
defaults := DefaultBanSettings()
|
||||
if settings.AutoBanDurationHours <= 0 {
|
||||
settings.AutoBanDurationHours = defaults.AutoBanDurationHours
|
||||
}
|
||||
if settings.MaliciousPathThreshold <= 0 {
|
||||
settings.MaliciousPathThreshold = defaults.MaliciousPathThreshold
|
||||
}
|
||||
if settings.AdminLoginFailureThreshold <= 0 {
|
||||
settings.AdminLoginFailureThreshold = defaults.AdminLoginFailureThreshold
|
||||
}
|
||||
if settings.UserLoginFailureThreshold <= 0 {
|
||||
settings.UserLoginFailureThreshold = defaults.UserLoginFailureThreshold
|
||||
}
|
||||
if settings.AbuseWindowHours <= 0 {
|
||||
settings.AbuseWindowHours = defaults.AbuseWindowHours
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func (s *BanService) CreateManualBan(target, reason, createdBy string, expiresAt time.Time) (BanRecord, error) {
|
||||
return s.createBan(target, reason, BanSourceManual, createdBy, expiresAt, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (s *BanService) createBan(target, reason, source, createdBy string, expiresAt, now time.Time) (BanRecord, error) {
|
||||
normalized, err := NormalizeBanTarget(target)
|
||||
if err != nil {
|
||||
return BanRecord{}, err
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return BanRecord{}, fmt.Errorf("ban reason is required")
|
||||
}
|
||||
if !expiresAt.After(now) {
|
||||
return BanRecord{}, fmt.Errorf("ban expiration must be in the future")
|
||||
}
|
||||
record := BanRecord{
|
||||
ID: randomID(12),
|
||||
Target: strings.TrimSpace(target),
|
||||
Normalized: normalized,
|
||||
Reason: reason,
|
||||
Source: source,
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ExpiresAt: expiresAt.UTC(),
|
||||
}
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return BanRecord{}, err
|
||||
}
|
||||
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(bansBucket).Put([]byte(record.ID), data)
|
||||
})
|
||||
return record, err
|
||||
}
|
||||
|
||||
func NormalizeBanTarget(target string) (string, error) {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("ban target is required")
|
||||
}
|
||||
if strings.Contains(target, "/") {
|
||||
_, network, err := net.ParseCIDR(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid CIDR target")
|
||||
}
|
||||
return network.String(), nil
|
||||
}
|
||||
ip := net.ParseIP(target)
|
||||
if ip == nil {
|
||||
return "", fmt.Errorf("invalid IP target")
|
||||
}
|
||||
return ip.String(), nil
|
||||
}
|
||||
|
||||
func (s *BanService) ListBans() ([]BanRecord, error) {
|
||||
records := []BanRecord{}
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(bansBucket).ForEach(func(_, value []byte) error {
|
||||
var record BanRecord
|
||||
if err := json.Unmarshal(value, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
records = append(records, record)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].CreatedAt.After(records[j].CreatedAt)
|
||||
})
|
||||
return records, err
|
||||
}
|
||||
|
||||
func (s *BanService) Unban(id string, now time.Time) error {
|
||||
id = strings.TrimSpace(id)
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(bansBucket)
|
||||
data := bucket.Get([]byte(id))
|
||||
if data == nil {
|
||||
return ErrBanNotFound
|
||||
}
|
||||
var record BanRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
now = now.UTC()
|
||||
record.UnbannedAt = &now
|
||||
record.UpdatedAt = now
|
||||
next, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bucket.Put([]byte(id), next)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BanService) Match(ip string, now time.Time) (MatchedBan, bool, error) {
|
||||
parsed := net.ParseIP(strings.TrimSpace(ip))
|
||||
if parsed == nil {
|
||||
return MatchedBan{}, false, nil
|
||||
}
|
||||
now = now.UTC()
|
||||
var matched BanRecord
|
||||
err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(bansBucket)
|
||||
return bucket.ForEach(func(key, value []byte) error {
|
||||
if matched.ID != "" {
|
||||
return nil
|
||||
}
|
||||
var record BanRecord
|
||||
if err := json.Unmarshal(value, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
if !record.Active(now) || !banTargetMatches(record.Normalized, parsed) {
|
||||
return nil
|
||||
}
|
||||
record.LastMatchedAt = &now
|
||||
record.UpdatedAt = now
|
||||
next, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bucket.Put(key, next); err != nil {
|
||||
return err
|
||||
}
|
||||
matched = record
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return MatchedBan{Ban: matched, IP: ip}, matched.ID != "", err
|
||||
}
|
||||
|
||||
func (r BanRecord) Active(now time.Time) bool {
|
||||
return r.UnbannedAt == nil && r.ExpiresAt.After(now.UTC())
|
||||
}
|
||||
|
||||
func (r BanRecord) Status(now time.Time) string {
|
||||
switch {
|
||||
case r.UnbannedAt != nil:
|
||||
return "unbanned"
|
||||
case !r.ExpiresAt.After(now.UTC()):
|
||||
return "expired"
|
||||
default:
|
||||
return "active"
|
||||
}
|
||||
}
|
||||
|
||||
func banTargetMatches(target string, ip net.IP) bool {
|
||||
if strings.Contains(target, "/") {
|
||||
if _, network, err := net.ParseCIDR(target); err == nil {
|
||||
return network.Contains(ip)
|
||||
}
|
||||
return false
|
||||
}
|
||||
targetIP := net.ParseIP(target)
|
||||
return targetIP != nil && targetIP.Equal(ip)
|
||||
}
|
||||
|
||||
func (s *BanService) ListRules() ([]BanRule, error) {
|
||||
rules := []BanRule{}
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(banRulesBucket).ForEach(func(key, value []byte) error {
|
||||
if string(key) == string(defaultBanRulesSeed) {
|
||||
return nil
|
||||
}
|
||||
var rule BanRule
|
||||
if err := json.Unmarshal(value, &rule); err != nil {
|
||||
return err
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(rules, func(i, j int) bool {
|
||||
return strings.ToLower(rules[i].Pattern) < strings.ToLower(rules[j].Pattern)
|
||||
})
|
||||
return rules, err
|
||||
}
|
||||
|
||||
func (s *BanService) SaveRules(patterns []string, now time.Time) error {
|
||||
now = now.UTC()
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(banRulesBucket)
|
||||
deleteKeys := [][]byte{}
|
||||
if err := bucket.ForEach(func(key, _ []byte) error {
|
||||
if string(key) == string(defaultBanRulesSeed) {
|
||||
return nil
|
||||
}
|
||||
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range deleteKeys {
|
||||
if err := bucket.Delete(key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, pattern := range patterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" || seen[strings.ToLower(pattern)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToLower(pattern)] = true
|
||||
rule := BanRule{ID: randomID(10), Pattern: pattern, Enabled: true, CreatedAt: now, UpdatedAt: now}
|
||||
data, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bucket.Put([]byte(rule.ID), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BanService) DeleteRule(id string) error {
|
||||
return s.db.Update(func(tx *bbolt.Tx) error {
|
||||
return tx.Bucket(banRulesBucket).Delete([]byte(strings.TrimSpace(id)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BanService) MaliciousPattern(path string) (string, error) {
|
||||
if shouldSkipMaliciousPath(path) {
|
||||
return "", nil
|
||||
}
|
||||
rules, err := s.ListRules()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
lowerPath := strings.ToLower(path)
|
||||
for _, rule := range rules {
|
||||
if rule.Enabled && strings.Contains(lowerPath, strings.ToLower(rule.Pattern)) {
|
||||
return rule.Pattern, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func shouldSkipMaliciousPath(path string) bool {
|
||||
return path == "/health" || path == "/healthz" || path == "/api/v1/health" || strings.HasPrefix(path, "/static/")
|
||||
}
|
||||
|
||||
func (s *BanService) RecordAbuse(ip, kind, detail string, threshold int, now time.Time) (AbuseResult, error) {
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return AbuseResult{}, err
|
||||
}
|
||||
if !settings.AutoBanEnabled {
|
||||
return AbuseResult{Enabled: false}, nil
|
||||
}
|
||||
if threshold <= 0 {
|
||||
return AbuseResult{Enabled: true}, nil
|
||||
}
|
||||
now = now.UTC()
|
||||
window := time.Duration(settings.AbuseWindowHours) * time.Hour
|
||||
key := abuseKey(ip, kind)
|
||||
var event AbuseEvent
|
||||
var triggered bool
|
||||
var ban BanRecord
|
||||
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(abuseEventsBucket)
|
||||
data := bucket.Get([]byte(key))
|
||||
if data != nil {
|
||||
if err := json.Unmarshal(data, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if data == nil || now.Sub(event.FirstSeen) > window {
|
||||
event = AbuseEvent{Key: key, IP: ip, Kind: kind, FirstSeen: now}
|
||||
}
|
||||
event.Count++
|
||||
event.LastSeen = now
|
||||
event.Detail = detail
|
||||
next, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bucket.Put([]byte(key), next); err != nil {
|
||||
return err
|
||||
}
|
||||
triggered = event.Count >= threshold
|
||||
return nil
|
||||
})
|
||||
if err != nil || !triggered {
|
||||
return AbuseResult{Event: event, Triggered: false, Enabled: true}, err
|
||||
}
|
||||
reason := fmt.Sprintf("%s threshold reached: %s", strings.ReplaceAll(kind, "_", " "), detail)
|
||||
ban, err = s.createBan(ip, reason, BanSourceAuto, "", now.Add(time.Duration(settings.AutoBanDurationHours)*time.Hour), now)
|
||||
if err != nil {
|
||||
return AbuseResult{}, err
|
||||
}
|
||||
return AbuseResult{Event: event, Ban: ban, Triggered: true, Enabled: true}, nil
|
||||
}
|
||||
|
||||
func (s *BanService) CleanupAbuseEvents(now time.Time) (int, error) {
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cutoff := now.UTC().Add(-time.Duration(settings.AbuseWindowHours) * time.Hour)
|
||||
cleaned := 0
|
||||
err = s.db.Update(func(tx *bbolt.Tx) error {
|
||||
bucket := tx.Bucket(abuseEventsBucket)
|
||||
deleteKeys := [][]byte{}
|
||||
if err := bucket.ForEach(func(key, value []byte) error {
|
||||
var event AbuseEvent
|
||||
if err := json.Unmarshal(value, &event); err != nil {
|
||||
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||
return nil
|
||||
}
|
||||
if event.LastSeen.Before(cutoff) {
|
||||
deleteKeys = append(deleteKeys, append([]byte(nil), key...))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range deleteKeys {
|
||||
if err := bucket.Delete(key); err != nil {
|
||||
return err
|
||||
}
|
||||
cleaned++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return cleaned, err
|
||||
}
|
||||
|
||||
func abuseKey(ip, kind string) string {
|
||||
return kind + ":" + strings.TrimSpace(ip)
|
||||
}
|
||||
117
backend/libs/services/bans_test.go
Normal file
117
backend/libs/services/bans_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBanServiceMatchesIPAndCIDR(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
ipBan, err := bans.createBan("203.0.113.5", "single IP", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan IP returned error: %v", err)
|
||||
}
|
||||
cidrBan, err := bans.createBan("198.51.100.0/24", "CIDR", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan CIDR returned error: %v", err)
|
||||
}
|
||||
|
||||
if matched, ok, err := bans.Match("203.0.113.5", now); err != nil || !ok || matched.Ban.ID != ipBan.ID {
|
||||
t.Fatalf("Match IP = %+v, %v, %v", matched, ok, err)
|
||||
}
|
||||
if matched, ok, err := bans.Match("198.51.100.42", now); err != nil || !ok || matched.Ban.ID != cidrBan.ID {
|
||||
t.Fatalf("Match CIDR = %+v, %v, %v", matched, ok, err)
|
||||
}
|
||||
if _, ok, err := bans.Match("192.0.2.1", now); err != nil || ok {
|
||||
t.Fatalf("Match unrelated = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanServiceIgnoresExpiredAndUnbanned(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
expired, err := bans.createBan("203.0.113.6", "expired", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan expired returned error: %v", err)
|
||||
}
|
||||
if _, ok, err := bans.Match("203.0.113.6", now.Add(2*time.Hour)); err != nil || ok {
|
||||
t.Fatalf("expired Match = %v, %v", ok, err)
|
||||
}
|
||||
active, err := bans.createBan("203.0.113.7", "active", BanSourceManual, "test", now.Add(time.Hour), now)
|
||||
if err != nil {
|
||||
t.Fatalf("createBan active returned error: %v", err)
|
||||
}
|
||||
if err := bans.Unban(active.ID, now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("Unban returned error: %v", err)
|
||||
}
|
||||
if _, ok, err := bans.Match("203.0.113.7", now.Add(2*time.Minute)); err != nil || ok {
|
||||
t.Fatalf("unbanned Match = %v, %v", ok, err)
|
||||
}
|
||||
if expired.Status(now.Add(2*time.Hour)) != "expired" {
|
||||
t.Fatalf("expired status = %q", expired.Status(now.Add(2*time.Hour)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanServiceAutoBanThresholdsAndDisabled(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
if result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now); err != nil || result.Enabled {
|
||||
t.Fatalf("disabled RecordAbuse = %+v, %v", result, err)
|
||||
}
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
if err := bans.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(time.Duration(i)*time.Minute))
|
||||
if err != nil || result.Triggered {
|
||||
t.Fatalf("RecordAbuse before threshold = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
result, err := bans.RecordAbuse("203.0.113.8", AbuseKindMaliciousPath, "/.env", 3, now.Add(3*time.Minute))
|
||||
if err != nil || !result.Triggered || result.Ban.ID == "" {
|
||||
t.Fatalf("RecordAbuse threshold = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanServiceMaliciousPathRules(t *testing.T) {
|
||||
bans := newTestBanService(t)
|
||||
if pattern, err := bans.MaliciousPattern("/foo/.ENV"); err != nil || pattern == "" {
|
||||
t.Fatalf("MaliciousPattern .env = %q, %v", pattern, err)
|
||||
}
|
||||
if pattern, err := bans.MaliciousPattern("/static/.env"); err != nil || pattern != "" {
|
||||
t.Fatalf("MaliciousPattern static = %q, %v", pattern, err)
|
||||
}
|
||||
if err := bans.SaveRules([]string{"/custom-probe"}, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("SaveRules returned error: %v", err)
|
||||
}
|
||||
if pattern, err := bans.MaliciousPattern("/x/CUSTOM-probe"); err != nil || pattern != "/custom-probe" {
|
||||
t.Fatalf("MaliciousPattern custom = %q, %v", pattern, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBanService(t *testing.T) *BanService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("NewUploadService returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := upload.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
})
|
||||
bans, err := NewBanService(upload.DB())
|
||||
if err != nil {
|
||||
t.Fatalf("NewBanService returned error: %v", err)
|
||||
}
|
||||
return bans
|
||||
}
|
||||
75
backend/libs/services/proxy.go
Normal file
75
backend/libs/services/proxy.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type clientIPContextKey struct{}
|
||||
|
||||
func WithClientIP(r *http.Request, ip string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), clientIPContextKey{}, ip))
|
||||
}
|
||||
|
||||
func ClientIPFromContext(r *http.Request) (string, bool) {
|
||||
ip, ok := r.Context().Value(clientIPContextKey{}).(string)
|
||||
return ip, ok && ip != ""
|
||||
}
|
||||
|
||||
// ClientIP resolves the effective client IP. When trustedProxies is empty,
|
||||
// forwarded headers are trusted for easy reverse-proxy/container defaults.
|
||||
func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string {
|
||||
remoteIP := remoteIPOnly(remoteAddr)
|
||||
if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) {
|
||||
if ip := firstForwardedIP(forwardedFor); ip != "" {
|
||||
return ip
|
||||
}
|
||||
if ip := strings.TrimSpace(realIP); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func remoteIPOnly(remoteAddr string) string {
|
||||
host := strings.TrimSpace(remoteAddr)
|
||||
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = splitHost
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
func firstForwardedIP(forwardedFor string) string {
|
||||
for _, part := range strings.Split(forwardedFor, ",") {
|
||||
ip := strings.TrimSpace(part)
|
||||
if ip != "" {
|
||||
return strings.Trim(ip, "[]")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func remoteTrusted(remoteIP string, trustedProxies []string) bool {
|
||||
parsed := net.ParseIP(remoteIP)
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
for _, trusted := range trustedProxies {
|
||||
trusted = strings.TrimSpace(trusted)
|
||||
if trusted == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trusted, "/") {
|
||||
if _, network, err := net.ParseCIDR(trusted); err == nil && network.Contains(parsed) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(trusted); ip != nil && ip.Equal(parsed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
29
backend/libs/services/proxy_test.go
Normal file
29
backend/libs/services/proxy_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClientIPTrustsForwardedHeadersByDefault(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "203.0.113.10, 10.0.0.2", "198.51.100.2", nil)
|
||||
if ip != "203.0.113.10" {
|
||||
t.Fatalf("ClientIP = %q, want forwarded IP", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPUsesTrustedProxyCIDRs(t *testing.T) {
|
||||
trusted := []string{"127.0.0.1", "172.16.0.0/12"}
|
||||
ip := ClientIP("172.20.0.4:6070", "203.0.113.11", "", trusted)
|
||||
if ip != "203.0.113.11" {
|
||||
t.Fatalf("trusted ClientIP = %q", ip)
|
||||
}
|
||||
spoofed := ClientIP("198.51.100.20:6070", "203.0.113.12", "203.0.113.13", trusted)
|
||||
if spoofed != "198.51.100.20" {
|
||||
t.Fatalf("untrusted ClientIP = %q, want remote addr", spoofed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPFallsBackToRealIP(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "", "203.0.113.14", nil)
|
||||
if ip != "203.0.113.14" {
|
||||
t.Fatalf("ClientIP = %q, want real IP", ip)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -484,19 +483,3 @@ func normalizeBackendID(id string) string {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func ClientIP(remoteAddr, forwardedFor string) string {
|
||||
if forwardedFor != "" {
|
||||
parts := strings.Split(forwardedFor, ",")
|
||||
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
host := remoteAddr
|
||||
if strings.Contains(remoteAddr, ":") {
|
||||
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = splitHost
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
@@ -150,17 +150,18 @@
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
/* Links: classic blue, underlined, purple when visited. */
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand) {
|
||||
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
|
||||
are styled as their own Win98 controls below, so they're excluded here. */
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab) {
|
||||
color: #0000ee;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):visited {
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):visited {
|
||||
color: #551a8b;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):hover {
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):hover {
|
||||
color: #ee0000;
|
||||
}
|
||||
|
||||
@@ -188,14 +189,25 @@
|
||||
padding-left: calc(0.85rem + 1px);
|
||||
}
|
||||
|
||||
/* The primary call-to-action gets the blue title-bar gradient. */
|
||||
/* The primary call-to-action is a glossy raised blue button. A vertical
|
||||
gradient + strong 3D bevel keeps it clearly a button (and distinct from the
|
||||
horizontal title-bar gradient). */
|
||||
:root[data-theme="retro"] .button-primary {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
background: linear-gradient(to bottom, #2f86e0 0%, #0a3aa0 52%, #000078 100%);
|
||||
color: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #00003a, inset 1px 1px 0 #7fc0ff, inset -2px -2px 0 #001a6a, inset 2px 2px 0 #3f9fe8;
|
||||
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button-primary:hover {
|
||||
background: linear-gradient(to right, #0a0a9a, 80%, #1a90dd);
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button-primary:active {
|
||||
box-shadow: inset 1px 1px 0 #00003a, inset -1px -1px 0 #7fc0ff;
|
||||
padding-top: calc(0.45rem + 1px);
|
||||
padding-left: calc(0.85rem + 1px);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .button-danger {
|
||||
@@ -276,7 +288,8 @@
|
||||
the API section cards. Pages where a heading sits below an icon or kicker
|
||||
(download/preview/login) keep the inset heading from the base h1 rule. */
|
||||
:root[data-theme="retro"] .card-content > h1:first-child,
|
||||
:root[data-theme="retro"] .docs-header h1 {
|
||||
:root[data-theme="retro"] .docs-header h1,
|
||||
:root[data-theme="retro"] .download-view-wide .download-card h1 {
|
||||
margin: -1.5rem -1.5rem 1rem;
|
||||
}
|
||||
|
||||
@@ -440,3 +453,170 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* App / admin shell (dashboard, account, admin pages) */
|
||||
/* These use dark revamp tokens by default, which are unreadable on the black */
|
||||
/* retro desktop. Re-skin them as Win98 chrome: a raised silver sidebar with */
|
||||
/* solid links, light page headers on the desktop, and bevelled stat cards. */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/* Sidebar = raised silver panel. */
|
||||
:root[data-theme="retro"] .app-sidebar,
|
||||
:root[data-theme="retro"] .admin-shell .app-sidebar {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .sidebar-link {
|
||||
color: #000000;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .sidebar-link:hover,
|
||||
:root[data-theme="retro"] .sidebar-link.is-active,
|
||||
:root[data-theme="retro"] .admin-shell .sidebar-link.is-active {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
border-color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .sidebar-sep {
|
||||
background: #808080;
|
||||
height: 2px;
|
||||
box-shadow: 0 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
/* Page header sits on the black desktop: light kicker, plain light title
|
||||
(not a floating title bar), light subtitle. */
|
||||
:root[data-theme="retro"] .admin-header .kicker {
|
||||
color: #ffd966;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .admin-header .muted-copy {
|
||||
color: #cfd8ff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .admin-header h1 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: block;
|
||||
background: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .admin-header h1::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Collection / nav tabs become small bevelled buttons. */
|
||||
:root[data-theme="retro"] .tab {
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
font-weight: 700;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .tab:hover {
|
||||
background: #d4d0c8;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .tab.is-active {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Metric cards = sunken white stat boxes with crisp black numbers. */
|
||||
:root[data-theme="retro"] .metric-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .metric-card span {
|
||||
color: #404040;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .metric-card strong {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* The "+ Collection" popover becomes a small floating window. */
|
||||
:root[data-theme="retro"] .new-collection-body {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* The storage inline edit form panel. */
|
||||
:root[data-theme="retro"] .storage-edit-form {
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Download / box page */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/* The decorative file glyph above the title doesn't suit a Win98 window. */
|
||||
:root[data-theme="retro"] .file-emblem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The download window's content is left-aligned like a real file manager. */
|
||||
:root[data-theme="retro"] .download-view-wide .download-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Expiry shown as a sunken status field with a little clock. */
|
||||
:root[data-theme="retro"] .badge-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .badge-expiry {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0.7rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .badge-expiry::before {
|
||||
content: "\23F1 ";
|
||||
}
|
||||
|
||||
/* List / Thumbnails / Preview images = a Win98 toolbar (menubar) of flat
|
||||
buttons that raise on hover and depress when active. */
|
||||
:root[data-theme="retro"] .view-toolbar {
|
||||
justify-content: flex-start;
|
||||
gap: 2px;
|
||||
margin-top: 1rem;
|
||||
padding: 3px;
|
||||
background: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #ffffff, inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .view-toolbar .button {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .view-toolbar .button:hover {
|
||||
background: #c0c0c0;
|
||||
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .view-toolbar .button.is-active {
|
||||
background: #d4d0c8;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,75 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logs-filter-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
align-items: end;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.logs-filter-card label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-filter-card label span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.logs-table td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.logs-table code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.compact-form {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compact-form textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-grid-two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.logs-filter-card {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.logs-filter-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Inline row edit (details/summary in table cells) */
|
||||
.row-edit {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
153
backend/templates/pages/admin_bans.html
Normal file
153
backend/templates/pages/admin_bans.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{{define "admin_bans.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-bans-title">
|
||||
<aside class="app-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
|
||||
<a class="sidebar-link" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav"><a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a></nav>
|
||||
<hr class="sidebar-sep">
|
||||
<form class="sidebar-logout" action="/admin/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<p class="kicker">Operator console</p>
|
||||
<h1 id="admin-bans-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">Manual IP/CIDR bans and optional automatic abuse protection.</p>
|
||||
</div>
|
||||
<a class="button button-outline" href="/admin/logs">Open logs</a>
|
||||
</div>
|
||||
|
||||
{{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}}
|
||||
{{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}}
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article>
|
||||
<article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
|
||||
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>
|
||||
<article class="metric-card"><span>Auto-ban</span><strong>{{if .Data.Bans.Settings.AutoBanEnabled}}Enabled{{else}}Off{{end}}</strong></article>
|
||||
</div>
|
||||
|
||||
<div class="admin-grid-two">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<h2>Manual ban</h2>
|
||||
<form class="settings-form compact-form" action="/admin/bans" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label><span>IP or CIDR</span><input name="target" placeholder="203.0.113.10 or 203.0.113.0/24" required></label>
|
||||
<label><span>Reason</span><input name="reason" placeholder="Repeated abuse" required></label>
|
||||
<label><span>Ban until</span><input type="datetime-local" name="expires_at" required></label>
|
||||
<button class="button button-danger" type="submit">Ban target</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<h2>Auto-ban settings</h2>
|
||||
<form class="settings-form compact-form" action="/admin/bans/settings" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="auto_ban_enabled" {{if .Data.Bans.Settings.AutoBanEnabled}}checked{{end}}>
|
||||
<span>Enable automatic bans</span>
|
||||
</label>
|
||||
<label><span>Auto-ban duration (hours)</span><input type="number" min="1" name="auto_ban_duration_hours" value="{{.Data.Bans.Settings.AutoBanDurationHours}}" required></label>
|
||||
<label><span>Abuse window (hours)</span><input type="number" min="1" name="abuse_window_hours" value="{{.Data.Bans.Settings.AbuseWindowHours}}" required></label>
|
||||
<label><span>Malicious path threshold</span><input type="number" min="1" name="malicious_path_threshold" value="{{.Data.Bans.Settings.MaliciousPathThreshold}}" required></label>
|
||||
<label><span>Admin login failures</span><input type="number" min="1" name="admin_login_failure_threshold" value="{{.Data.Bans.Settings.AdminLoginFailureThreshold}}" required></label>
|
||||
<label><span>User login failures</span><input type="number" min="1" name="user_login_failure_threshold" value="{{.Data.Bans.Settings.UserLoginFailureThreshold}}" required></label>
|
||||
<button class="button button-primary" type="submit">Save auto-ban settings</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Ban records</h2>
|
||||
<p>Active records block requests before the normal route handler runs.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="logs-filter-card" method="get" action="/admin/bans">
|
||||
<label><span>Status</span>
|
||||
<select name="status">
|
||||
<option value="">All</option>
|
||||
<option value="active" {{if eq .Data.Bans.Status "active"}}selected{{end}}>Active</option>
|
||||
<option value="expired" {{if eq .Data.Bans.Status "expired"}}selected{{end}}>Expired</option>
|
||||
<option value="unbanned" {{if eq .Data.Bans.Status "unbanned"}}selected{{end}}>Unbanned</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Search</span><input name="q" value="{{.Data.Bans.Query}}" placeholder="IP, CIDR, reason"></label>
|
||||
<button class="button button-outline" type="submit">Filter</button>
|
||||
</form>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Reason</th>
|
||||
<th>Source</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
<th>Last match</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data.Bans.Bans}}
|
||||
<tr>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Reason}}</td>
|
||||
<td>{{.Source}}</td>
|
||||
<td><span class="badge">{{.Status}}</span></td>
|
||||
<td>{{.ExpiresAt}}</td>
|
||||
<td>{{.LastMatched}}</td>
|
||||
<td>
|
||||
{{if eq .Status "active"}}
|
||||
<form action="/admin/bans/{{.ID}}/unban" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-outline" type="submit">Unban</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="muted-copy">No action</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7">No bans match this filter.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<h2>Malicious path rules</h2>
|
||||
<p class="muted-copy">One case-insensitive substring per line. These rules only create bans when auto-ban is enabled.</p>
|
||||
<form class="settings-form compact-form" action="/admin/bans/rules" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label><span>Patterns</span><textarea name="patterns" rows="10" spellcheck="false">{{.Data.Bans.RulePatterns}}</textarea></label>
|
||||
<button class="button button-primary" type="submit">Save rules</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
105
backend/templates/pages/admin_logs.html
Normal file
105
backend/templates/pages/admin_logs.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{{define "admin_logs.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-logs-title">
|
||||
<aside class="app-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
|
||||
<a class="sidebar-link" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav"><a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a></nav>
|
||||
<hr class="sidebar-sep">
|
||||
<form class="sidebar-logout" action="/admin/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<p class="kicker">Operator console</p>
|
||||
<h1 id="admin-logs-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">Browse JSON log lines from the local log files.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="logs-filter-card" method="get" action="/admin/logs">
|
||||
<label><span>Date</span>
|
||||
<select name="date">
|
||||
<option value="all" {{if eq .Data.Logs.Date "all"}}selected{{end}}>All dates</option>
|
||||
{{range .Data.Logs.Dates}}<option value="{{.}}" {{if eq $.Data.Logs.Date .}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Severity</span>
|
||||
<select name="severity">
|
||||
<option value="" {{if eq .Data.Logs.Severity ""}}selected{{end}}>All</option>
|
||||
<option value="dev" {{if eq .Data.Logs.Severity "dev"}}selected{{end}}>dev</option>
|
||||
<option value="user_activity" {{if eq .Data.Logs.Severity "user_activity"}}selected{{end}}>user_activity</option>
|
||||
<option value="warn" {{if eq .Data.Logs.Severity "warn"}}selected{{end}}>warn</option>
|
||||
<option value="error" {{if eq .Data.Logs.Severity "error"}}selected{{end}}>error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Source</span><input name="source" value="{{.Data.Logs.Source}}" placeholder="auth, admin, upload"></label>
|
||||
<label><span>Search</span><input name="q" value="{{.Data.Logs.Query}}" placeholder="message, IP, path, user id"></label>
|
||||
<label><span>Sort</span>
|
||||
<select name="sort">
|
||||
<option value="desc" {{if eq .Data.Logs.Sort "desc"}}selected{{end}}>Newest first</option>
|
||||
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="button button-primary" type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Log entries</h2>
|
||||
<p>Showing up to 500 entries. {{.Data.Logs.TotalShown}} currently visible.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Severity</th>
|
||||
<th>Source</th>
|
||||
<th>Code</th>
|
||||
<th>Message</th>
|
||||
<th>Actor/IP</th>
|
||||
<th>Route</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data.Logs.Entries}}
|
||||
<tr>
|
||||
<td><span class="log-time">{{.Date}} {{.Time}}</span></td>
|
||||
<td><span class="badge">{{.Severity}}</span></td>
|
||||
<td>{{.Source}}</td>
|
||||
<td>{{.Code}}</td>
|
||||
<td>
|
||||
<strong>{{.Message}}</strong>
|
||||
{{if .Details}}<details><summary>Details</summary><code>{{.Details}}</code></details>{{end}}
|
||||
</td>
|
||||
<td>{{if .UserID}}<code>{{.UserID}}</code>{{end}}{{if .IP}}<br><span>{{.IP}}</span>{{end}}</td>
|
||||
<td>{{if .Method}}{{.Method}}{{end}} {{if .Path}}<code>{{.Path}}</code>{{end}}{{if .Status}}<br><span>Status {{.Status}}</span>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7">No log entries match those filters.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link is-active" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav"><a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a></nav>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<a class="sidebar-link is-active" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<div class="file-emblem" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
|
||||
</div>
|
||||
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Download files{{end}}</h1>
|
||||
<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>
|
||||
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
|
||||
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
|
||||
|
||||
{{if .Data.Locked}}
|
||||
<form class="unlock-form" action="/d/{{.Data.Box.ID}}/unlock" method="post">
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
{{if .Data.Files}}
|
||||
<div class="badge-row">
|
||||
<span class="badge">Expires {{.Data.ExpiresLabel}}</span>
|
||||
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
|
||||
{{if .Data.MaxDownloads}}<span class="badge">{{.Data.DownloadCount}} / {{.Data.MaxDownloads}} downloads</span>{{end}}
|
||||
</div>
|
||||
|
||||
|
||||
22
docker-compose-prod.yml
Normal file
22
docker-compose-prod.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
app:
|
||||
image: tea.chunkbyte.com/kato/warpbox-dev:latest
|
||||
container_name: warpbox-dev
|
||||
env_file:
|
||||
- .prod.env
|
||||
environment:
|
||||
WARPBOX_ADDR: ":8080"
|
||||
WARPBOX_DATA_DIR: /data
|
||||
WARPBOX_STATIC_DIR: /app/static
|
||||
WARPBOX_TEMPLATE_DIR: /app/templates
|
||||
volumes:
|
||||
- ./data:/data:Z
|
||||
ports:
|
||||
- "6070:8080"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/health >/dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
1
scripts/env/dev.env.example
vendored
1
scripts/env/dev.env.example
vendored
@@ -30,3 +30,4 @@ WARPBOX_USER_STORAGE_BACKEND=local
|
||||
WARPBOX_READ_TIMEOUT=15s
|
||||
WARPBOX_WRITE_TIMEOUT=60s
|
||||
WARPBOX_IDLE_TIMEOUT=120s
|
||||
WARPBOX_TRUSTED_PROXIES=
|
||||
|
||||
Reference in New Issue
Block a user