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.
This commit is contained in:
2026-05-31 21:52:56 +03:00
parent 2d04a42736
commit 10ed806153
38 changed files with 2310 additions and 43 deletions

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"
)
@@ -801,6 +804,182 @@ func TestAdminStorageTestingPageRendersHistory(t *testing.T) {
}
}
func TestAdminLogsAndBansPagesRender(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
logDir := filepath.Join(app.cfg.DataDir, "logs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
logPath := filepath.Join(logDir, "2026-05-31.log")
line := `{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}` + "\n"
if err := os.WriteFile(logPath, []byte(line), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
logsRequest := httptest.NewRequest(http.MethodGet, "/admin/logs?q=box123", nil)
logsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
logsResponse := httptest.NewRecorder()
app.AdminLogs(logsResponse, logsRequest)
if logsResponse.Code != http.StatusOK {
t.Fatalf("AdminLogs status = %d, body = %s", logsResponse.Code, logsResponse.Body.String())
}
logsBody := logsResponse.Body.String()
if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") {
t.Fatalf("AdminLogs missing expected log entry: %s", logsBody)
}
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
bansResponse := httptest.NewRecorder()
app.AdminBans(bansResponse, bansRequest)
if bansResponse.Code != http.StatusOK {
t.Fatalf("AdminBans status = %d, body = %s", bansResponse.Code, bansResponse.Body.String())
}
if !strings.Contains(bansResponse.Body.String(), "Manual ban") || !strings.Contains(bansResponse.Body.String(), "Auto-ban settings") {
t.Fatalf("AdminBans missing ban controls: %s", bansResponse.Body.String())
}
}
func TestAdminCanCreateAndUnbanIPBan(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
expiresAt := time.Now().Add(24 * time.Hour).Format("2006-01-02T15:04")
request := httptest.NewRequest(http.MethodPost, "/admin/bans", strings.NewReader("target=203.0.113.90&reason=test&expires_at="+expiresAt+"&csrf_token=test-csrf"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
response := httptest.NewRecorder()
app.AdminCreateBan(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("AdminCreateBan status = %d, body = %s", response.Code, response.Body.String())
}
records, err := app.banService.ListBans()
if err != nil {
t.Fatalf("ListBans returned error: %v", err)
}
if len(records) != 1 || records[0].Normalized != "203.0.113.90" {
t.Fatalf("records = %+v", records)
}
unbanRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/"+records[0].ID+"/unban", strings.NewReader("csrf_token=test-csrf"))
unbanRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
unbanRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
unbanRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
unbanRequest.SetPathValue("banID", records[0].ID)
unbanResponse := httptest.NewRecorder()
app.AdminUnban(unbanResponse, unbanRequest)
if unbanResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminUnban status = %d, body = %s", unbanResponse.Code, unbanResponse.Body.String())
}
if _, ok, err := app.banService.Match("203.0.113.90", time.Now().UTC()); err != nil || ok {
t.Fatalf("unbanned Match = %v, %v", ok, err)
}
}
func TestAdminCanUpdateBanSettingsAndRules(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
adminToken := createAdminSession(t, app)
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/settings", strings.NewReader("auto_ban_enabled=on&auto_ban_duration_hours=48&abuse_window_hours=12&malicious_path_threshold=2&admin_login_failure_threshold=4&user_login_failure_threshold=5&csrf_token=test-csrf"))
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
settingsRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
settingsResponse := httptest.NewRecorder()
app.AdminBanSettingsPost(settingsResponse, settingsRequest)
if settingsResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminBanSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
}
settings, err := app.banService.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
if !settings.AutoBanEnabled || settings.AutoBanDurationHours != 48 || settings.MaliciousPathThreshold != 2 {
t.Fatalf("settings = %+v", settings)
}
rulesRequest := httptest.NewRequest(http.MethodPost, "/admin/bans/rules", strings.NewReader("patterns=%2Fcustom-one%0A%2Fcustom-two&csrf_token=test-csrf"))
rulesRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rulesRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
rulesRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
rulesResponse := httptest.NewRecorder()
app.AdminBanRulesPost(rulesResponse, rulesRequest)
if rulesResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminBanRulesPost status = %d, body = %s", rulesResponse.Code, rulesResponse.Body.String())
}
if pattern, err := app.banService.MaliciousPattern("/x/custom-two"); err != nil || pattern != "/custom-two" {
t.Fatalf("MaliciousPattern = %q, %v", pattern, err)
}
}
func TestLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
settings, err := app.banService.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
settings.UserLoginFailureThreshold = 2
if err := app.banService.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
for i := 0; i < 2; i++ {
request := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader("email=admin@example.test&password=wrong"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.RemoteAddr = "203.0.113.91:1234"
response := httptest.NewRecorder()
app.LoginPost(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("LoginPost status = %d", response.Code)
}
}
if _, ok, err := app.banService.Match("203.0.113.91", time.Now().UTC()); err != nil || !ok {
t.Fatalf("Match after login failures = %v, %v", ok, err)
}
}
func TestAdminLoginFailuresCreateAutoBanWhenEnabled(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
settings, err := app.banService.Settings()
if err != nil {
t.Fatalf("Settings returned error: %v", err)
}
settings.AutoBanEnabled = true
settings.AdminLoginFailureThreshold = 2
if err := app.banService.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings returned error: %v", err)
}
app.cfg.AdminToken = "correct-token"
for i := 0; i < 2; i++ {
request := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader("token=wrong"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.RemoteAddr = "203.0.113.92:1234"
response := httptest.NewRecorder()
app.AdminLoginPost(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("AdminLoginPost status = %d", response.Code)
}
}
if _, ok, err := app.banService.Match("203.0.113.92", time.Now().UTC()); err != nil || !ok {
t.Fatalf("Match after admin login failures = %v, %v", ok, err)
}
}
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
t.Helper()
user, err := app.authService.UserByID(userID)

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

@@ -213,7 +213,12 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close()
t.Fatalf("NewSettingsService returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service, authService, settingsService), func() {
banService, err := services.NewBanService(service.DB())
if err != nil {
service.Close()
t.Fatalf("NewBanService returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() {
if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}