Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 56s
- Add `WARPBOX_RESUMABLE_CHUNK_MODE` and `WARPBOX_RESUMABLE_CHUNK_PATH` environment variables to configure temporary chunk storage. - Implement strict file validation for resuming uploads to ensure selected files match the pending session's metadata. - Add `PLANS.md` to document development stages, roadmap, and API specifications (including batching and resumable flows).
2129 lines
68 KiB
Go
2129 lines
68 KiB
Go
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"
|
||
settings.ResumableUploadsEnabled = r.FormValue("resumable_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 := parsePositiveFloat(r.FormValue("resumable_chunk_size_mb")); value > 0 {
|
||
settings.ResumableChunkSizeMB = value
|
||
}
|
||
if value := parsePositiveInt(r.FormValue("resumable_retention_hours")); value > 0 {
|
||
settings.ResumableRetentionHours = value
|
||
}
|
||
if value := strings.TrimSpace(r.FormValue("resumable_chunk_mode")); value != "" {
|
||
settings.ResumableChunkMode = value
|
||
}
|
||
settings.ResumableChunkPath = strings.TrimSpace(r.FormValue("resumable_chunk_path"))
|
||
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"
|
||
}
|
||
|
||
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
|
||
}
|