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

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