feat(security): add trusted proxies and abuse event cleanup
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m38s
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user