3 Commits

Author SHA1 Message Date
10ed806153 feat(security): add trusted proxies and abuse event cleanup
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
- Add `WARPBOX_TRUSTED_PROXIES` configuration to restrict accepted forwarded client IP headers to specific proxy IPs/CIDRs, securing client IP resolution.
- Integrate `BanService` into the background cleanup job to automatically purge expired abuse and ban evidence events.
- Update documentation with reverse proxy security guidelines and a production systemd deployment guide.
2026-05-31 21:52:56 +03:00
2d04a42736 feat(ui): style admin shell for retro theme and add prod docker config
- Update the retro theme CSS to style the dashboard, account, and admin pages with a classic Windows 98 aesthetic (silver sidebar, bevelled tabs, sunken metric cards).
- Exclude sidebar links and tabs from default retro link styles to ensure readability.
- Add `docker-compose-prod.yml` for production deployments.
- Add `.prod.env` to `.gitignore`.
2026-05-31 21:03:00 +03:00
42449b3322 feat: add application versioning support to backend and UI
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
- Introduce APP_VERSION build argument and environment variable in Dockerfile.
- Load AppVersion from environment variables in the configuration loader.
- Pass the application version to the HTML renderer and expose it to templates via PageData.
- Update tests to verify the version is correctly rendered in the footer.
2026-05-31 20:21:37 +03:00
44 changed files with 2473 additions and 58 deletions

View File

@@ -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
View File

@@ -12,5 +12,6 @@ backend/static/uploads/*
.env
.env.*
!.env.example
.prod.env
scripts/env/dev.env
docker-compose.yml

View File

@@ -16,12 +16,15 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
FROM alpine:3.22
ARG APP_VERSION=dev
RUN apk add --no-cache ca-certificates ffmpeg wget
ENV WARPBOX_ADDR=:8080 \
WARPBOX_DATA_DIR=/data \
WARPBOX_STATIC_DIR=/app/static \
WARPBOX_TEMPLATE_DIR=/app/templates
WARPBOX_TEMPLATE_DIR=/app/templates \
APP_VERSION=${APP_VERSION}
WORKDIR /app

View File

@@ -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
View 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`.

View File

@@ -12,6 +12,7 @@ import (
type Config struct {
AppName string
AppVersion string
Environment string
Addr string
BaseURL string
@@ -22,6 +23,7 @@ type Config struct {
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
TrustedProxies []string
JobsEnabled bool
CleanupEnabled bool
CleanupEvery time.Duration
@@ -54,6 +56,7 @@ type SettingsDefaults struct {
func Load() (Config, error) {
cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
AppVersion: envString("APP_VERSION", "dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
@@ -64,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),
@@ -178,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 == "" {

View File

@@ -5,8 +5,11 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"warpbox.dev/backend/libs/services"
)
@@ -569,6 +572,9 @@ func TestHomeReflectsUploadPolicySettings(t *testing.T) {
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
t.Fatalf("home did not reflect policy settings: %s", body)
}
if !strings.Contains(body, "warpbox.dev · test ·") {
t.Fatalf("home footer did not include app version: %s", body)
}
}
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
@@ -798,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)

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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 {

View File

@@ -179,6 +179,7 @@ func newTestApp(t *testing.T) (*App, func()) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := config.Config{
AppName: "warpbox.dev",
AppVersion: "test",
BaseURL: "http://example.test",
DataDir: filepath.Join(root, "data"),
StaticDir: staticDir,
@@ -197,7 +198,7 @@ func newTestApp(t *testing.T) (*App, func()) {
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil {
service.Close()
t.Fatalf("NewRenderer returned error: %v", err)
@@ -212,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)
}

View File

@@ -13,7 +13,7 @@ import (
)
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
if err != nil {
return nil, 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{

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

@@ -10,11 +10,13 @@ import (
type Renderer struct {
templates map[string]*template.Template
appName string
appVersion string
baseURL string
}
type PageData struct {
AppName string
AppVersion string
BaseURL string
Title string
Description string
@@ -25,7 +27,7 @@ type PageData struct {
Data any
}
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
func NewRenderer(templateDir, appName, appVersion, baseURL string) (*Renderer, error) {
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
if err != nil {
return nil, err
@@ -58,12 +60,14 @@ func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
return &Renderer{
templates: templates,
appName: appName,
appVersion: appVersion,
baseURL: baseURL,
}, nil
}
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
data.AppName = r.appName
data.AppVersion = r.appVersion
data.BaseURL = r.baseURL
data.CurrentYear = time.Now().Year()

View File

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

View File

@@ -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 {

View File

@@ -60,7 +60,7 @@
</main>
<footer class="site-footer">
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
<span>{{.AppName}} · {{.AppVersion}} · {{.CurrentYear}} · self-hosted</span>
<label class="theme-picker">
<span>Theme</span>
<select data-theme-select aria-label="Site theme">

View File

@@ -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">

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

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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

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

View File

@@ -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">

View File

@@ -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
View 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

View File

@@ -30,3 +30,4 @@ WARPBOX_USER_STORAGE_BACKEND=local
WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_IDLE_TIMEOUT=120s
WARPBOX_TRUSTED_PROXIES=

View File

@@ -14,6 +14,11 @@ set -a
source "${ENV_FILE}"
set +a
if [[ -z "${APP_VERSION:-}" ]]; then
APP_VERSION="$(git -C "${ROOT_DIR}" describe --tags --abbrev=0 2>/dev/null || printf 'dev')"
export APP_VERSION
fi
if [[ "${WARPBOX_DATA_DIR:-}" != /* ]]; then
export WARPBOX_DATA_DIR="${ROOT_DIR}/${WARPBOX_DATA_DIR:-data}"
fi