package handlers import ( "bufio" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "os" "path" "path/filepath" "sort" "strconv" "strings" "time" "warpbox.dev/backend/libs/helpers" "warpbox.dev/backend/libs/jobs" "warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/web" ) const adminCookieName = "warpbox_admin" type adminPageData struct { Stats services.AdminStats Boxes []adminBoxView Users []adminUserView Settings services.UploadPolicySettings Storage []services.StorageBackendView UserEdit adminUserEditView StorageForm adminStorageFormView StorageTest adminStorageTestView StorageTypes []adminStorageProviderView Logs adminLogsView Bans adminBansView Overview adminOverview Section string PageTitle string LastInviteURL string Notice string Error string } type adminLogsView struct { Entries []adminLogEntry Dates []string Date string Severity string Source string Query string Sort string TotalShown int Total int Page int PerPage int PerPageOptions []int TotalPages int RangeFrom int RangeTo int PageLinks []adminFilesPageLink HasPrev bool HasNext bool PrevHref string NextHref string } var adminLogsPageSizes = []int{50, 100, 250, 500} const adminLogsDefaultPageSize = 100 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 ProviderLabel string Action string BackHref string Config services.StorageBackendConfig } type adminStorageTestView struct { Config services.StorageBackendConfig UsageLabel string Tests []services.StorageSpeedTest CanRun bool } type adminStorageSpeedTestJSON struct { ID string `json:"id"` Mode string `json:"mode"` ModeLabel string `json:"modeLabel"` Status string `json:"status"` Stage string `json:"stage"` Progress int `json:"progress"` CustomLabel string `json:"customLabel,omitempty"` StartedLabel string `json:"startedLabel"` FinishedLabel string `json:"finishedLabel"` Files int `json:"files"` SizeLabel string `json:"sizeLabel"` WriteSpeed string `json:"writeSpeed"` ReadSpeed string `json:"readSpeed"` Error string `json:"error,omitempty"` } type adminStorageProviderView struct { Provider string Label string Description string Icon string } type adminOverview struct { UploadDays []adminChartBar StorageDays []adminChartBar StatusBars []adminStatBar } type adminChartBar struct { Label string Value string HeightPx int RawValue int64 } type adminStatBar struct { Label string Value string RawValue int WidthPercent int } type adminBoxView struct { ID string Owner string CreatedAt string ExpiresAt string FileCount int TotalSizeLabel string DownloadCount int MaxDownloads int Protected bool Expired bool } type adminUserView struct { ID string Username string Email string Role string Status string StorageUsed string StorageQuota string DailyUsed string StorageBackend string CreatedAt string } type adminUserEditView struct { ID string Username string Email string Role string Status string StorageUsed string DailyUsed string EffectiveStorage string EffectiveDaily string EffectiveMaxDays int EffectiveDailyBoxes int EffectiveActiveBoxes int EffectiveBackend string MaxUploadMB string DailyUploadMB string StorageQuotaMB string MaxDays string DailyBoxes string ActiveBoxes string ShortWindowRequests string StorageBackendID string } func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) { if a.isAdmin(r) { http.Redirect(w, r, "/admin", http.StatusSeeOther) return } a.renderAdminLogin(w, r, http.StatusOK, "") } func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) { if !a.rateLimiter.Allow("admin-login:"+uploadClientIP(r), 10, time.Minute, time.Now().UTC()) { a.renderAdminLogin(w, r, http.StatusTooManyRequests, "Too many admin login attempts.") return } if err := r.ParseForm(); err != nil { a.renderAdminLogin(w, r, http.StatusBadRequest, "Unable to read login form.") return } if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken { 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 } http.SetCookie(w, &http.Cookie{ Name: adminCookieName, Value: adminCookieValue(a.cfg.AdminToken), Path: "/admin", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, Expires: time.Now().Add(12 * time.Hour), }) a.logger.Info("admin login", "source", "admin", "severity", "user_activity", "code", 2301, "ip", uploadClientIP(r)) http.Redirect(w, r, "/admin", http.StatusSeeOther) } 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, Value: "", Path: "/admin", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) http.Redirect(w, r, "/admin/login", http.StatusSeeOther) } func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } stats, err := a.uploadService.AdminStats() if err != nil { http.Error(w, "unable to load admin stats", http.StatusInternalServerError) return } allBoxes, err := a.uploadService.AdminBoxes(0) if err != nil { http.Error(w, "unable to load boxes", http.StatusInternalServerError) return } overview := buildAdminOverview(allBoxes, stats) recent := a.recentBoxViews(allBoxes, 8) a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{ Title: "Admin overview", Description: "Warpbox admin overview.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Stats: stats, Boxes: recent, Overview: overview, Section: "overview", PageTitle: "Admin overview", }, }) } // recentBoxViews renders the newest boxes (already sorted newest-first by the // service) into display rows, resolving owner labels. func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxView { if limit > 0 && len(boxes) > limit { boxes = boxes[:limit] } cache := map[string]string{} rows := make([]adminBoxView, 0, len(boxes)) for _, box := range boxes { rows = append(rows, adminBoxView{ ID: box.ID, Owner: a.boxOwnerLabel(box.OwnerID, cache), CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04"), ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04"), FileCount: box.FileCount, TotalSizeLabel: box.TotalSizeLabel, DownloadCount: box.DownloadCount, MaxDownloads: box.MaxDownloads, Protected: box.Protected, Expired: box.Expired, }) } return rows } // buildAdminOverview computes the last-14-day upload/storage series plus a few // status distributions for the overview dashboard. func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview { const days = 14 const chartMaxHeightPx = 150 now := time.Now().UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) counts := make([]int, days) bytes := make([]int64, days) labels := make([]string, days) for i := 0; i < days; i++ { day := today.AddDate(0, 0, -(days - 1 - i)) labels[i] = day.Format("Jan 2") } for _, box := range boxes { created := box.CreatedAt.UTC() day := time.Date(created.Year(), created.Month(), created.Day(), 0, 0, 0, 0, time.UTC) offset := int(today.Sub(day).Hours() / 24) idx := days - 1 - offset if idx < 0 || idx >= days { continue } counts[idx]++ bytes[idx] += box.TotalSize } maxCount := 0 var maxBytes int64 for i := 0; i < days; i++ { if counts[i] > maxCount { maxCount = counts[i] } if bytes[i] > maxBytes { maxBytes = bytes[i] } } uploadDays := make([]adminChartBar, days) storageDays := make([]adminChartBar, days) for i := 0; i < days; i++ { uploadDays[i] = adminChartBar{ Label: labels[i], Value: strconv.Itoa(counts[i]), HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx), RawValue: int64(counts[i]), } storageDays[i] = adminChartBar{ Label: labels[i], Value: helpers.FormatBytes(bytes[i]), HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx), RawValue: bytes[i], } } activeBoxes := stats.TotalBoxes - stats.ExpiredBoxes if activeBoxes < 0 { activeBoxes = 0 } maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes) statusBars := []adminStatBar{ {Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)}, {Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)}, {Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)}, } return adminOverview{ UploadDays: uploadDays, StorageDays: storageDays, StatusBars: statusBars, } } func scaleHeightPx(value, max int64, maxHeightPx int) int { if max <= 0 || value <= 0 { return 0 } height := int(value * int64(maxHeightPx) / max) if height < 8 { height = 8 } if height > maxHeightPx { return maxHeightPx } return height } func percentOf(value, total int) int { if total <= 0 || value <= 0 { return 0 } return value * 100 / total } func maxInt(values ...int) int { max := 0 for _, value := range values { if value > max { max = value } } return max } func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } stats, err := a.uploadService.AdminStats() if err != nil { http.Error(w, "unable to load admin stats", http.StatusInternalServerError) return } users, err := a.authService.ListUsers() if err != nil { http.Error(w, "unable to load users", http.StatusInternalServerError) return } rows := make([]adminUserView, 0, len(users)) settings, err := a.settingsService.UploadPolicy() if err != nil { http.Error(w, "unable to load settings", http.StatusInternalServerError) return } for _, user := range users { storageUsed, _ := a.uploadService.UserActiveStorageUsed(user.ID) usage, _ := a.settingsService.UsageForUser(user.ID, time.Now().UTC()) policy := a.settingsService.EffectivePolicyForUser(settings, user) quota := "unlimited" if policy.StorageQuotaSet { quota = formatMB(policy.StorageQuotaMB) } rows = append(rows, adminUserView{ ID: user.ID, Username: user.Username, Email: user.Email, Role: user.Role, Status: user.Status, StorageUsed: services.FormatMegabytesFromBytes(storageUsed), StorageQuota: quota, DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes), StorageBackend: policy.StorageBackendID, CreatedAt: user.CreatedAt.Format("Jan 2 15:04"), }) } a.renderPage(w, r, http.StatusOK, "admin_users.html", web.PageData{ Title: "Admin users", Description: "Manage Warpbox users and invites.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Stats: stats, Users: rows, Section: "users", PageTitle: "Users", LastInviteURL: r.URL.Query().Get("invite"), }, }) } func (a *App) AdminEditUser(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } user, err := a.authService.UserByID(r.PathValue("userID")) if err != nil { http.NotFound(w, r) return } settings, err := a.settingsService.UploadPolicy() if err != nil { http.Error(w, "unable to load settings", http.StatusInternalServerError) return } storage, err := a.storageBackendViews() if err != nil { http.Error(w, "unable to load storage", http.StatusInternalServerError) return } edit, err := a.adminUserEdit(user, settings) if err != nil { http.Error(w, "unable to load user policy", http.StatusInternalServerError) return } a.renderPage(w, r, http.StatusOK, "admin_user_edit.html", web.PageData{ Title: "Edit user", Description: "Edit a Warpbox user.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ UserEdit: edit, Storage: storage, Section: "users", PageTitle: "Edit user", LastInviteURL: r.URL.Query().Get("invite"), Error: r.URL.Query().Get("error"), }, }) } func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } settings, err := a.settingsService.UploadPolicy() if err != nil { http.Error(w, "unable to load settings", http.StatusInternalServerError) return } storage, err := a.storageBackendViews() if err != nil { http.Error(w, "unable to load storage", http.StatusInternalServerError) return } a.renderPage(w, r, http.StatusOK, "admin_settings.html", web.PageData{ Title: "Admin settings", Description: "Manage Warpbox upload policy.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Settings: settings, Storage: storage, Section: "settings", PageTitle: "Settings", }, }) } func (a *App) AdminSettingsPost(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/settings", http.StatusSeeOther) return } settings, err := a.settingsService.UploadPolicy() if err != nil { http.Error(w, "unable to load settings", http.StatusInternalServerError) return } settings.AnonymousUploadsEnabled = r.FormValue("anonymous_uploads_enabled") == "on" if value := parsePositiveInt(r.FormValue("usage_retention_days")); value > 0 { settings.UsageRetentionDays = value } if value := parsePositiveFloat(r.FormValue("local_storage_max_gb")); value > 0 { settings.LocalStorageMaxGB = value } if value := parsePositiveInt(r.FormValue("anonymous_max_days")); value > 0 { settings.AnonymousMaxDays = value } if value := parsePositiveInt(r.FormValue("user_max_days")); value > 0 { settings.UserMaxDays = value } if value := parsePositiveInt(r.FormValue("anonymous_daily_boxes")); value > 0 { settings.AnonymousDailyBoxes = value } if value := parsePositiveInt(r.FormValue("user_daily_boxes")); value > 0 { settings.UserDailyBoxes = value } if value := parsePositiveInt(r.FormValue("anonymous_active_boxes")); value > 0 { settings.AnonymousActiveBoxes = value } if value := parsePositiveInt(r.FormValue("user_active_boxes")); value > 0 { settings.UserActiveBoxes = value } if value := parsePositiveInt(r.FormValue("short_window_requests")); value > 0 { settings.ShortWindowRequests = value } if value := parsePositiveInt(r.FormValue("short_window_seconds")); value > 0 { settings.ShortWindowSeconds = value } if value := r.FormValue("anonymous_storage_backend"); value != "" { settings.AnonymousStorageBackend = value } if value := r.FormValue("user_storage_backend"); value != "" { settings.UserStorageBackend = value } if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("anonymous_max_upload_mb")); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if settings.AnonymousDailyUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("anonymous_daily_upload_mb")); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if settings.UserDailyUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("user_daily_upload_mb")); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if settings.DefaultUserStorageMB, err = services.ParseMegabytesValue(r.FormValue("default_user_storage_mb")); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if settings.UsageRetentionDays <= 0 { http.Error(w, "usage retention days must be positive", http.StatusBadRequest) return } if _, err := a.uploadService.Storage().BackendConfig(settings.AnonymousStorageBackend); err != nil { http.Error(w, "anonymous storage backend not found", http.StatusBadRequest) return } if _, err := a.uploadService.Storage().BackendConfig(settings.UserStorageBackend); err != nil { http.Error(w, "user storage backend not found", http.StatusBadRequest) return } if err := a.settingsService.UpdateUploadPolicy(settings); err != nil { 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 } if services.ProtectedBanTarget(r.FormValue("target"), a.cfg.TrustedProxies) { http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Refusing to ban loopback or trusted proxy addresses."), http.StatusSeeOther) return } 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 } settings, err := a.settingsService.UploadPolicy() if err != nil { http.Error(w, "unable to load settings", http.StatusInternalServerError) return } views, err := a.storageBackendViews() if err != nil { http.Error(w, "unable to load storage", http.StatusInternalServerError) return } a.renderPage(w, r, http.StatusOK, "admin_storage.html", web.PageData{ Title: "Admin storage", Description: "Manage Warpbox storage backends.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Settings: settings, Storage: views, Section: "storage", PageTitle: "Storage", Notice: r.URL.Query().Get("notice"), Error: r.URL.Query().Get("error"), }, }) } func (a *App) AdminNewStorage(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } a.renderPage(w, r, http.StatusOK, "admin_storage_new.html", web.PageData{ Title: "Add storage", Description: "Choose a Warpbox storage provider.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Section: "storage", PageTitle: "Add storage", StorageTypes: adminStorageProviderOptions(), Error: r.URL.Query().Get("error"), }, }) } func (a *App) AdminNewStorageProvider(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } rawProvider := adminStorageProviderFromRequest(r) if !validAdminStorageProvider(rawProvider) { http.NotFound(w, r) return } provider := normalizeAdminStorageProvider(rawProvider) a.renderStorageForm(w, r, http.StatusOK, adminStorageFormView{ Mode: "create", Provider: provider, ProviderLabel: adminStorageProviderLabel(provider), Action: "/admin/storage/new/" + provider, BackHref: "/admin/storage/new", Config: services.StorageBackendConfig{ Provider: provider, Type: adminStorageTypeForProvider(provider), UseSSL: true, }, }, r.URL.Query().Get("error")) } func (a *App) AdminEditStorageForm(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID")) if err != nil || cfg.ID == services.StorageBackendLocal { http.NotFound(w, r) return } provider := normalizeAdminStorageProvider(cfg.Provider) a.renderStorageForm(w, r, http.StatusOK, adminStorageFormView{ Mode: "edit", Provider: provider, ProviderLabel: adminStorageProviderLabel(provider), Action: "/admin/storage/" + cfg.ID + "/edit", BackHref: "/admin/storage", Config: cfg, }, r.URL.Query().Get("error")) } func (a *App) AdminStorageTests(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID")) if err != nil { http.NotFound(w, r) return } tests, err := a.uploadService.Storage().ListSpeedTests(cfg.ID, 100) if err != nil { http.Error(w, "unable to load storage tests", http.StatusInternalServerError) return } var usage int64 if cfg.Enabled { if backend, err := a.uploadService.Storage().Backend(cfg.ID); err == nil { usage, _ = backend.Usage(context.Background()) } } a.renderPage(w, r, http.StatusOK, "admin_storage_tests.html", web.PageData{ Title: cfg.Name + " tests", Description: "Storage speed-test history.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Section: "storage", PageTitle: cfg.Name + " tests", StorageTest: adminStorageTestView{ Config: cfg, UsageLabel: services.FormatMegabytesFromBytes(usage), Tests: tests, CanRun: cfg.Enabled && cfg.LastTestSuccess, }, Notice: r.URL.Query().Get("notice"), Error: r.URL.Query().Get("error"), }, }) } func (a *App) AdminStorageTestsJSON(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } cfg, err := a.uploadService.Storage().BackendConfig(r.PathValue("backendID")) if err != nil { http.NotFound(w, r) return } tests, err := a.uploadService.Storage().ListSpeedTests(cfg.ID, 100) if err != nil { http.Error(w, "unable to load storage tests", http.StatusInternalServerError) return } payload := struct { Tests []adminStorageSpeedTestJSON `json:"tests"` }{Tests: adminStorageSpeedTestsJSON(tests)} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(payload) } func (a *App) renderStorageForm(w http.ResponseWriter, r *http.Request, status int, form adminStorageFormView, message string) { a.renderPage(w, r, status, "admin_storage_form.html", web.PageData{ Title: form.ProviderLabel + " storage", Description: "Configure Warpbox storage.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Section: "storage", PageTitle: form.ProviderLabel + " storage", StorageForm: form, Error: message, }, }) } func (a *App) AdminCreateStorage(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } rawProvider := adminStorageProviderFromRequest(r) if !validAdminStorageProvider(rawProvider) { http.NotFound(w, r) return } provider := normalizeAdminStorageProvider(rawProvider) if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/admin/storage/new/"+provider, http.StatusSeeOther) return } _, err := a.uploadService.Storage().CreateBackend(a.storageConfigFromForm(r, provider)) if err != nil { 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) } func (a *App) AdminEditStorage(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/storage", http.StatusSeeOther) return } provider := normalizeAdminStorageProvider(r.FormValue("provider")) _, err := a.uploadService.Storage().UpdateBackend(r.PathValue("backendID"), a.storageConfigFromForm(r, provider)) if err != nil { 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) } func (a *App) AdminTestStorage(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } next := "/admin/storage" if r.FormValue("next") == "tests" { 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) } func (a *App) AdminStartStorageSpeedTest(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/storage?error="+url.QueryEscape("Unable to read speed test form."), http.StatusSeeOther) return } options := services.StorageSpeedTestOptions{ Mode: r.FormValue("mode"), CustomFileCount: parsePositiveInt(r.FormValue("custom_file_count")), CustomFileSizeMB: parsePositiveFloat(r.FormValue("custom_file_size_mb")), } test, err := a.uploadService.Storage().StartSpeedTestWithOptions(r.PathValue("backendID"), options) if err != nil { http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) 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) } func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } id := r.PathValue("backendID") cfg, err := a.uploadService.Storage().BackendConfig(id) if err != nil { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } if cfg.ID == services.StorageBackendLocal { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape("local storage cannot be deleted"), http.StatusSeeOther) return } deletedBoxes, err := a.uploadService.DeleteBoxesForStorageBackend(id, "storage-delete") if err != nil { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } resetAnonymous, resetUsersDefault, err := a.settingsService.ResetStorageBackend(id) if err != nil { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } clearedUsers, err := a.authService.ClearStorageBackendOverrides(id) if err != nil { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } if err := a.uploadService.Storage().DeleteBackend(id, false); err != nil { http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) return } notice := fmt.Sprintf("Storage backend deleted. Removed %d related boxes and cleared %d user overrides.", deletedBoxes, clearedUsers) if resetAnonymous || resetUsersDefault { notice += " Global storage defaults were reset to local." } 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?notice="+url.QueryEscape(notice), http.StatusSeeOther) } func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } 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) } func (a *App) AdminRunStorageThumbnails(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } 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) } func (a *App) AdminVerifyStorageBackends(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } 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 } passed := 0 failed := 0 for _, cfg := range configs { if !cfg.Enabled { continue } if _, err := a.uploadService.Storage().TestBackend(cfg.ID); err != nil { failed++ continue } 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) } func (a *App) AdminUpdateUserQuota(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/users", http.StatusSeeOther) return } var quota *float64 if r.FormValue("storage_quota_mb") != "" { parsed, err := services.ParseMegabytesValue(r.FormValue("storage_quota_mb")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } quota = &parsed } if err := a.authService.SetUserStorageQuota(r.PathValue("userID"), quota); err != nil { 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) } func (a *App) AdminUpdateUserPolicy(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/users", http.StatusSeeOther) return } policy := services.UserPolicy{ MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")), DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")), StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")), MaxDays: optionalIntAllowUnlimited(r.FormValue("max_days")), DailyBoxes: optionalIntAllowUnlimited(r.FormValue("daily_boxes")), ActiveBoxes: optionalIntAllowUnlimited(r.FormValue("active_boxes")), ShortWindowRequests: optionalIntAllowUnlimited(r.FormValue("short_window_requests")), } if backendID := r.FormValue("storage_backend_id"); backendID != "" { if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil { http.Error(w, "storage backend not found", http.StatusBadRequest) return } policy.StorageBackendID = &backendID } if err := a.authService.SetUserPolicy(r.PathValue("userID"), policy); err != nil { 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) } func (a *App) AdminUpdateUser(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/users/"+r.PathValue("userID")+"/edit", http.StatusSeeOther) return } policy := services.UserPolicy{ MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")), DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")), StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")), MaxDays: optionalIntAllowUnlimited(r.FormValue("max_days")), DailyBoxes: optionalIntAllowUnlimited(r.FormValue("daily_boxes")), ActiveBoxes: optionalIntAllowUnlimited(r.FormValue("active_boxes")), ShortWindowRequests: optionalIntAllowUnlimited(r.FormValue("short_window_requests")), } if backendID := r.FormValue("storage_backend_id"); backendID != "" { if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil { http.Redirect(w, r, "/admin/users/"+r.PathValue("userID")+"/edit?error="+url.QueryEscape("storage backend not found"), http.StatusSeeOther) return } policy.StorageBackendID = &backendID } if _, err := a.authService.UpdateUserAdminFields( r.PathValue("userID"), r.FormValue("username"), r.FormValue("email"), r.FormValue("role"), r.FormValue("status"), policy, ); err != nil { 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) } func (a *App) AdminUpdateUserStorage(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/users", http.StatusSeeOther) return } if backendID := r.FormValue("storage_backend_id"); backendID != "" { if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil { http.Error(w, "storage backend not found", http.StatusBadRequest) return } } if err := a.authService.SetUserStorageBackend(r.PathValue("userID"), r.FormValue("storage_backend_id")); err != nil { 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) } func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) { admin, ok := a.requireAdminUser(w, r) if !ok || !a.validateCSRF(w, r) { return } if err := r.ParseForm(); err != nil { http.Redirect(w, r, "/admin/users", http.StatusSeeOther) return } result, err := a.authService.CreateInvite(r.FormValue("email"), r.FormValue("role"), admin.ID, 7*24*time.Hour) if err != nil { 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, "ip", uploadClientIP(r), "email", r.FormValue("email"), "role", r.FormValue("role")) http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther) } func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } disabled := r.URL.Query().Get("disabled") != "false" if err := a.authService.DisableUser(r.PathValue("userID"), disabled); err != nil { 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) } func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) { admin, ok := a.requireAdminUser(w, r) if !ok || !a.validateCSRF(w, r) { return } result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID) if err != nil { 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 } http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther) } func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { return } boxID := r.PathValue("boxID") if err := a.uploadService.DeleteBox(boxID); err != nil { a.logger.Warn("admin delete failed", "source", "admin", "severity", "warn", "code", 4302, "box_id", boxID, "error", err.Error()) 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) } func (a *App) AdminViewBox(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return } box, err := a.uploadService.GetBox(r.PathValue("boxID")) if err != nil { http.NotFound(w, r) return } if a.uploadService.IsProtected(box) { http.SetCookie(w, &http.Cookie{ Name: unlockCookieName(box.ID), Value: a.uploadService.UnlockToken(box), Path: "/d/" + box.ID, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, Expires: box.ExpiresAt, }) a.logger.Info("admin bypassed box password", "source", "admin", "severity", "user_activity", "code", 2302, "box_id", box.ID) } http.Redirect(w, r, "/d/"+box.ID, http.StatusSeeOther) } func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status int, message string) { a.renderPage(w, r, status, "admin_login.html", web.PageData{ Title: "Admin login", Description: "Sign in to the Warpbox admin console.", Data: adminPageData{ Error: message, }, }) } func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool { if a.isAdmin(r) { return true } http.Redirect(w, r, "/admin/login", http.StatusSeeOther) return false } func (a *App) isAdmin(r *http.Request) bool { if user, ok := a.currentUser(r); ok && user.Role == services.UserRoleAdmin { return true } if a.cfg.AdminToken == "" { return false } cookie, err := r.Cookie(adminCookieName) if err != nil { return false } return cookie.Value == adminCookieValue(a.cfg.AdminToken) } func (a *App) requireAdminUser(w http.ResponseWriter, r *http.Request) (services.User, bool) { user, ok := a.currentUser(r) if ok && user.Role == services.UserRoleAdmin { return user, true } if a.cfg.AdminToken != "" && a.isAdmin(r) { return services.User{ID: "env-admin", Role: services.UserRoleAdmin, Status: services.UserStatusActive}, true } http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther) return services.User{}, false } func (a *App) currentPublicUser(r *http.Request) any { if user, ok := a.currentUser(r); ok { return a.authService.PublicUser(user) } return nil } func adminCookieValue(token string) string { sum := sha256.Sum256([]byte("warpbox-admin:" + token)) return hex.EncodeToString(sum[:]) } func parsePositiveInt(value string) int { parsed, err := strconv.Atoi(value) if err != nil { return 0 } return parsed } func parsePositiveFloat(value string) float64 { parsed, err := strconv.ParseFloat(value, 64) if err != nil { return 0 } return parsed } func optionalMB(value string) *float64 { if value == "" { return nil } parsed, err := services.ParseMegabytesLimitValue(value) if err != nil { return nil } return &parsed } func optionalMBAllowZero(value string) *float64 { if value == "" { return nil } parsed, err := strconv.ParseFloat(value, 64) // 0 and -1 both mean unlimited; reject other negatives. if err != nil || (parsed < 0 && parsed != -1) { return nil } return &parsed } func optionalInt(value string) *int { if value == "" { return nil } parsed, err := strconv.Atoi(value) if err != nil || parsed <= 0 { return nil } return &parsed } // optionalIntAllowUnlimited is like optionalInt but also accepts -1 (unlimited). func optionalIntAllowUnlimited(value string) *int { if value == "" { return nil } parsed, err := strconv.Atoi(value) if err != nil || (parsed <= 0 && parsed != -1) { return nil } return &parsed } 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 }) perPage := normalizePageSize(r.URL.Query().Get("per"), adminLogsDefaultPageSize, adminLogsPageSizes) total := len(entries) totalPages := (total + perPage - 1) / perPage if totalPages < 1 { totalPages = 1 } page := 1 if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 { page = parsed } if page > totalPages { page = totalPages } start := (page - 1) * perPage if start > total { start = total } end := start + perPage if end > total { end = total } rangeFrom := 0 if total > 0 { rangeFrom = start + 1 } state := adminLogsQuery{ Date: selectedDate, Severity: severity, Source: source, Query: r.URL.Query().Get("q"), Sort: sortOrder, Per: perPage, } links := make([]adminFilesPageLink, 0, 5) for p := page - 2; p <= page+2; p++ { if p < 1 || p > totalPages { continue } links = append(links, adminFilesPageLink{Page: p, Href: adminLogsHref(state, p), Active: p == page}) } return adminLogsView{ Entries: entries[start:end], Dates: dates, Date: selectedDate, Severity: severity, Source: source, Query: r.URL.Query().Get("q"), Sort: sortOrder, TotalShown: end - start, Total: total, Page: page, PerPage: perPage, PerPageOptions: adminLogsPageSizes, TotalPages: totalPages, RangeFrom: rangeFrom, RangeTo: end, PageLinks: links, HasPrev: page > 1, HasNext: page < totalPages, PrevHref: adminLogsHref(state, page-1), NextHref: adminLogsHref(state, page+1), }, nil } type adminLogsQuery struct { Date string Severity string Source string Query string Sort string Per int } func adminLogsHref(state adminLogsQuery, page int) string { values := url.Values{} if state.Date != "" { values.Set("date", state.Date) } if state.Severity != "" { values.Set("severity", state.Severity) } if state.Source != "" { values.Set("source", state.Source) } if state.Query != "" { values.Set("q", state.Query) } if state.Sort != "" && state.Sort != "desc" { values.Set("sort", state.Sort) } if state.Per > 0 && state.Per != adminLogsDefaultPageSize { values.Set("per", strconv.Itoa(state.Per)) } if page > 1 { values.Set("page", strconv.Itoa(page)) } if len(values) == 0 { return "/admin/logs" } return "/admin/logs?" + values.Encode() } 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 } if isHealthCheckLogEntry(raw) { continue } entries = append(entries, logEntryFromMap(raw)) } return entries, scanner.Err() } func isHealthCheckLogEntry(raw map[string]any) bool { path := strings.TrimSpace(firstLogString(raw, "path", "route")) if path == "" { return false } fields := strings.Fields(path) if len(fields) > 0 { path = fields[len(fields)-1] } if idx := strings.IndexByte(path, '?'); idx >= 0 { path = path[:idx] } return path == "/health" || path == "/healthz" || path == "/api/v1/health" } 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: services.IPOnly(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 { rows = append(rows, adminStorageSpeedTestJSON{ ID: test.ID, Mode: test.Mode, ModeLabel: test.ModeLabel(), Status: test.Status, Stage: test.Stage, Progress: test.ProgressPercent, CustomLabel: storageSpeedCustomLabel(test), StartedLabel: test.StartedLabel(), FinishedLabel: test.FinishedLabel(), Files: test.FilesWritten, SizeLabel: test.TotalSizeLabel(), WriteSpeed: test.WriteSpeedLabel(), ReadSpeed: test.ReadSpeedLabel(), Error: test.Error, }) } return rows } func storageSpeedCustomLabel(test services.StorageSpeedTest) string { if test.Mode != services.StorageSpeedModeCustom { return "" } return fmt.Sprintf("%d files × %s each", test.CustomFileCount, services.FormatMegabytesLabel(test.CustomFileSizeMB)) } func (a *App) storageConfigFromForm(r *http.Request, provider string) services.StorageBackendConfig { cfg := services.StorageBackendConfig{ Provider: provider, Name: r.FormValue("name"), } switch provider { case services.StorageProviderSFTP: cfg.Host = r.FormValue("host") cfg.Port = parsePositiveInt(r.FormValue("port")) cfg.Username = r.FormValue("username") cfg.Password = r.FormValue("password") cfg.PrivateKey = r.FormValue("private_key") cfg.HostKey = r.FormValue("host_key") cfg.RemotePath = r.FormValue("remote_path") case services.StorageProviderSMB: cfg.Host = r.FormValue("host") cfg.Port = parsePositiveInt(r.FormValue("port")) cfg.Share = r.FormValue("share") cfg.Domain = r.FormValue("domain") cfg.Username = r.FormValue("username") cfg.Password = r.FormValue("password") cfg.RemotePath = r.FormValue("remote_path") case services.StorageProviderWebDAV: cfg.Endpoint = r.FormValue("endpoint") cfg.Username = r.FormValue("username") cfg.Password = r.FormValue("password") cfg.RemotePath = r.FormValue("remote_path") case services.StorageProviderContabo: cfg.Endpoint = r.FormValue("endpoint") cfg.Region = r.FormValue("region") cfg.Bucket = r.FormValue("bucket") cfg.AccessKey = r.FormValue("access_key") cfg.SecretKey = r.FormValue("secret_key") cfg.UseSSL = true cfg.PathStyle = true default: cfg.Endpoint = r.FormValue("endpoint") cfg.Region = r.FormValue("region") cfg.Bucket = r.FormValue("bucket") cfg.AccessKey = r.FormValue("access_key") cfg.SecretKey = r.FormValue("secret_key") cfg.UseSSL = r.FormValue("use_ssl") == "on" cfg.PathStyle = r.FormValue("path_style") == "on" } return cfg } func adminStorageProviderOptions() []adminStorageProviderView { return []adminStorageProviderView{ {Provider: services.StorageProviderS3, Label: "S3 Bucket", Description: "Generic S3-compatible object storage.", Icon: "cloud"}, {Provider: services.StorageProviderContabo, Label: "Contabo Object Storage", Description: "Contabo COS with TLS and path-style lookup locked on.", Icon: "cloud"}, {Provider: services.StorageProviderSFTP, Label: "SFTP", Description: "SSH file transfer to a server or NAS.", Icon: "database"}, {Provider: services.StorageProviderSMB, Label: "Samba / SMB", Description: "Windows share or network attached storage.", Icon: "folder"}, {Provider: services.StorageProviderWebDAV, Label: "WebDAV", Description: "Nextcloud, ownCloud, or any WebDAV endpoint.", Icon: "sync"}, } } func normalizeAdminStorageProvider(provider string) string { switch provider { case services.StorageProviderContabo: return services.StorageProviderContabo case services.StorageProviderSFTP: return services.StorageProviderSFTP case services.StorageProviderSMB: return services.StorageProviderSMB case services.StorageProviderWebDAV: return services.StorageProviderWebDAV default: return services.StorageProviderS3 } } func adminStorageProviderFromRequest(r *http.Request) string { if provider := r.PathValue("provider"); provider != "" { return provider } return path.Base(r.URL.Path) } func validAdminStorageProvider(provider string) bool { switch provider { case services.StorageProviderS3, services.StorageProviderContabo, services.StorageProviderSFTP, services.StorageProviderSMB, services.StorageProviderWebDAV: return true default: return false } } func adminStorageProviderLabel(provider string) string { switch normalizeAdminStorageProvider(provider) { case services.StorageProviderContabo: return "Contabo Object Storage" case services.StorageProviderSFTP: return "SFTP" case services.StorageProviderSMB: return "Samba / SMB" case services.StorageProviderWebDAV: return "WebDAV" default: return "S3 Bucket" } } func adminStorageTypeForProvider(provider string) string { switch normalizeAdminStorageProvider(provider) { case services.StorageProviderSFTP: return services.StorageBackendSFTP case services.StorageProviderSMB: return services.StorageBackendSMB case services.StorageProviderWebDAV: return services.StorageBackendWebDAV default: return services.StorageBackendS3 } } func (a *App) storageBackendViews() ([]services.StorageBackendView, error) { configs, err := a.uploadService.Storage().ListBackendConfigs() if err != nil { return nil, err } views := make([]services.StorageBackendView, 0, len(configs)) for _, cfg := range configs { var usage int64 if backend, err := a.uploadService.Storage().BackendConfig(cfg.ID); err == nil && backend.Enabled { if concrete, err := a.uploadService.Storage().Backend(cfg.ID); err == nil { usage, _ = concrete.Usage(context.Background()) } } inUse, inUseReason, _ := a.storageBackendUseReason(cfg.ID) speedTests, _ := a.uploadService.Storage().ListSpeedTests(cfg.ID, 25) views = append(views, services.StorageBackendView{ Config: cfg, UsageBytes: usage, UsageLabel: services.FormatMegabytesFromBytes(usage), InUse: inUse, InUseReason: inUseReason, SpeedTests: speedTests, CanSpeedTest: cfg.LastTestSuccess, }) } return views, nil } func (a *App) adminUserEdit(user services.User, settings services.UploadPolicySettings) (adminUserEditView, error) { storageUsed, err := a.uploadService.UserActiveStorageUsed(user.ID) if err != nil { return adminUserEditView{}, err } usage, err := a.settingsService.UsageForUser(user.ID, time.Now().UTC()) if err != nil { return adminUserEditView{}, err } effective := a.settingsService.EffectivePolicyForUser(settings, user) view := adminUserEditView{ ID: user.ID, Username: user.Username, Email: user.Email, Role: user.Role, Status: user.Status, StorageUsed: services.FormatMegabytesFromBytes(storageUsed), DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes), EffectiveDaily: services.FormatMegabytesLabel(effective.DailyUploadMB), EffectiveMaxDays: effective.MaxDays, EffectiveDailyBoxes: effective.DailyBoxes, EffectiveActiveBoxes: effective.ActiveBoxes, EffectiveBackend: effective.StorageBackendID, MaxUploadMB: floatPtrString(user.Policy.MaxUploadMB), DailyUploadMB: floatPtrString(user.Policy.DailyUploadMB), StorageQuotaMB: floatPtrString(user.Policy.StorageQuotaMB), MaxDays: intPtrString(user.Policy.MaxDays), DailyBoxes: intPtrString(user.Policy.DailyBoxes), ActiveBoxes: intPtrString(user.Policy.ActiveBoxes), ShortWindowRequests: intPtrString(user.Policy.ShortWindowRequests), StorageBackendID: stringPtrString(user.Policy.StorageBackendID), } if effective.StorageQuotaSet { view.EffectiveStorage = services.FormatMegabytesLabel(effective.StorageQuotaMB) } else { view.EffectiveStorage = "unlimited" } return view, nil } func (a *App) storageBackendInUse(id string) (bool, error) { inUse, _, err := a.storageBackendUseReason(id) return inUse, err } func (a *App) storageBackendUseReason(id string) (bool, string, error) { settings, err := a.settingsService.UploadPolicy() if err != nil { return false, "", err } if settings.AnonymousStorageBackend == id { return true, "selected as the global anonymous storage backend", nil } if settings.UserStorageBackend == id { return true, "selected as the global user storage backend", nil } boxes, err := a.uploadService.ListBoxes(0) if err != nil { return false, "", err } for _, box := range boxes { if a.uploadService.BoxStorageBackendID(box) == id { return true, "used by existing boxes", nil } } users, err := a.authService.ListUsers() if err != nil { return false, "", err } for _, user := range users { if user.Policy.StorageBackendID != nil && *user.Policy.StorageBackendID == id { return true, "assigned to one or more users", nil } } return false, "", nil } func floatPtrString(value *float64) string { if value == nil { return "" } return strconv.FormatFloat(*value, 'f', -1, 64) } func intPtrString(value *int) string { if value == nil { return "" } return strconv.Itoa(*value) } func stringPtrString(value *string) string { if value == nil { return "" } return *value }