Files
warpbox-dev/backend/libs/handlers/admin.go
Daniel Legt cc91ce120d
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
feat(admin): allow editing boxes and deleting individual files
Introduce new admin capabilities to manage uploaded boxes and files:
- Add routes and handlers for editing boxes and deleting individual files.
- Implement `RemoveFileFromBox` in `UploadService` to delete a file's stored objects and remove it from the box (deleting the box if empty).
- Implement `AdminUpdateBox` in `UploadService` to update expiry, download limits, and clear password protection.
- Remove the unused `AdminFiles` handler.
- Add `.claude` to `.gitignore`.
2026-06-01 03:39:45 +03:00

1870 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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
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
}
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 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
}
boxes, err := a.adminBoxes(8)
if err != nil {
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
return
}
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: boxes,
Section: "overview",
PageTitle: "Admin overview",
},
})
}
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) adminBoxes(limit int) ([]adminBoxView, error) {
boxes, err := a.uploadService.AdminBoxes(limit)
if err != nil {
return nil, err
}
rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes {
owner := "Anonymous"
if box.OwnerID != "" {
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
owner = user.Email
} else {
owner = "User"
}
}
rows = append(rows, adminBoxView{
ID: box.ID,
Owner: owner,
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2 15:04"),
FileCount: box.FileCount,
TotalSizeLabel: box.TotalSizeLabel,
DownloadCount: box.DownloadCount,
MaxDownloads: box.MaxDownloads,
Protected: box.Protected,
Expired: box.Expired,
})
}
return rows, nil
}
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
})
if len(entries) > 500 {
entries = entries[:500]
}
return adminLogsView{
Entries: entries,
Dates: dates,
Date: selectedDate,
Severity: severity,
Source: source,
Query: r.URL.Query().Get("q"),
Sort: sortOrder,
TotalShown: len(entries),
}, nil
}
func availableLogDates(logDir string) ([]string, error) {
matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
if err != nil {
return nil, err
}
dates := make([]string, 0, len(matches))
for _, match := range matches {
name := strings.TrimSuffix(filepath.Base(match), ".log")
if name != "" {
dates = append(dates, name)
}
}
sort.Sort(sort.Reverse(sort.StringSlice(dates)))
return dates, nil
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func readLogEntries(file string) ([]adminLogEntry, error) {
handle, err := os.Open(file)
if err != nil {
return nil, err
}
defer handle.Close()
scanner := bufio.NewScanner(handle)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
entries := []adminLogEntry{}
for scanner.Scan() {
line := scanner.Bytes()
var raw map[string]any
if err := json.Unmarshal(line, &raw); err != nil {
continue
}
entries = append(entries, logEntryFromMap(raw))
}
return entries, scanner.Err()
}
func logEntryFromMap(raw map[string]any) adminLogEntry {
entry := adminLogEntry{
Date: logString(raw, "date"),
Time: logString(raw, "time"),
Source: logString(raw, "source"),
Severity: logString(raw, "severity"),
Code: logAnyString(raw["code"]),
Message: logString(raw, "log"),
Method: logString(raw, "method"),
Path: logString(raw, "path"),
Status: logAnyString(raw["status"]),
IP: 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
}