Files
warpbox-dev/backend/libs/handlers/admin.go

2118 lines
67 KiB
Go
Raw Normal View History

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
}