Files
warpbox/lib/server/admin.go
Daniel Legt a2c80ac105 feat(admin): make dashboard live and disk-aware
Wire dashboard panels to real alerts, activity, boxes, and users data instead of static mock rows.

Enable working dashboard actions (close alerts, close low alerts, cleanup expired boxes, exports, and navigation).

Update storage overview to use real filesystem free/total space from the uploads volume.

Make top alert chip data-driven across admin pages.
2026-05-04 10:54:44 +03:00

498 lines
15 KiB
Go

package server
import (
"net/http"
"strconv"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts"
"warpbox/lib/config"
"warpbox/lib/helpers"
"warpbox/lib/security"
"warpbox/lib/userstore"
)
const adminSessionCookie = "warpbox_admin_session"
const adminSessionMarker = "1"
type adminDashboardView struct {
ActiveBoxes int `json:"active_boxes"`
BoxesCreatedToday int `json:"boxes_created_today"`
PasswordedBoxes int `json:"passworded_boxes"`
StorageUsedLabel string `json:"storage_used_label"`
StorageFreeLabel string `json:"storage_free_label"`
StorageCapLabel string `json:"storage_cap_label"`
StorageMeter string `json:"storage_meter"`
StorageBackend string `json:"storage_backend"`
OpenAlerts int `json:"open_alerts"`
HighAlerts int `json:"high_alerts"`
MediumAlerts int `json:"medium_alerts"`
LowAlerts int `json:"low_alerts"`
TotalUsers int `json:"total_users"`
ActiveUsers int `json:"active_users"`
DisabledUsers int `json:"disabled_users"`
APIKeyCount int `json:"api_key_count"`
GuestUploadsLabel string `json:"guest_uploads_label"`
APIUploadsLabel string `json:"api_uploads_label"`
ZipDownloadsLabel string `json:"zip_downloads_label"`
Alerts []adminDashboardAlert `json:"alerts"`
Events []adminDashboardActivity `json:"events"`
Boxes []adminDashboardBox `json:"boxes"`
}
type adminDashboardAlert struct {
ID string `json:"id"`
Title string `json:"title"`
Severity string `json:"severity"`
Status string `json:"status"`
Group string `json:"group"`
Code string `json:"code"`
Trace string `json:"trace"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
CreatedAtLabel string `json:"created_at_label"`
Meta map[string]string `json:"meta,omitempty"`
}
type adminDashboardActivity struct {
ID string `json:"id"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Message string `json:"message"`
IP string `json:"ip"`
Path string `json:"path"`
Method string `json:"method"`
CreatedAt string `json:"created_at"`
CreatedAtLabel string `json:"created_at_label"`
Meta map[string]string `json:"meta,omitempty"`
TagClass string `json:"tag_class"`
TagLabel string `json:"tag_label"`
}
type adminDashboardBox struct {
ID string `json:"id"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
StatusClass string `json:"status_class"`
FileCount int `json:"file_count"`
CompleteFiles int `json:"complete_files"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"`
ExpiresAtLabel string `json:"expires_at_label"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
OpenURL string `json:"open_url"`
ZipURL string `json:"zip_url"`
Flags []string `json:"flags"`
}
func (app *App) adminLoginEnabled() bool {
return app.config.AdminLoginEnabled(app.config.AdminPassword != "")
}
func (app *App) adminAuthMiddleware(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
ctx.Abort()
return
}
token, err := ctx.Cookie(adminSessionCookie)
if err != nil || token != app.adminSessionToken() {
ctx.Redirect(http.StatusSeeOther, "/admin/login")
ctx.Abort()
return
}
ctx.Next()
}
func (app *App) adminSessionToken() string {
// A simple deterministic token derived from the admin credentials.
// This will improve when proper user/session storage is added.
return app.config.AdminUsername + ":" + app.config.AdminPassword
}
func (app *App) handleAdminLogin(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
// Already logged in.
if token, err := ctx.Cookie(adminSessionCookie); err == nil && token == app.adminSessionToken() {
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
return
}
ctx.HTML(http.StatusOK, "admin/login.html", gin.H{})
}
func (app *App) handleAdminLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ip := app.clientIP(ctx)
guard := app.securityGuard
if app.securityFeaturesEnabled() && guard == nil {
guard = security.NewGuard()
app.securityGuard = guard
}
if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) && guard.IsBanned(ip) {
app.logActivity("auth.admin.block", "high", "Blocked admin login from banned IP", ctx, nil)
ctx.HTML(http.StatusTooManyRequests, "admin/login.html", gin.H{
"ErrorMessage": "Too many failed attempts. Try again later.",
})
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
if username != app.config.AdminUsername || password != app.config.AdminPassword {
if app.securityFeaturesEnabled() && guard != nil && !guard.IsAdminWhitelisted(ip) {
banned, attempts := guard.RegisterFailedLogin(ip, app.config.SecurityLoginWindowSeconds, app.config.SecurityLoginMaxAttempts, app.config.SecurityBanSeconds)
app.logActivity("auth.admin.failed", "medium", "Failed admin login", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
if banned {
app.createAlert("Admin login brute-force blocked", "high", "security", "401", "auth.admin.bruteforce", "Too many failed admin logins triggered temporary ban.", map[string]string{"ip": ip, "attempts": strconv.Itoa(attempts)})
app.logActivity("security.ban", "high", "Auto-banned IP after admin login failures", ctx, map[string]string{"attempts": strconv.Itoa(attempts)})
}
}
ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{
"ErrorMessage": "Invalid username or password.",
})
return
}
app.logActivity("auth.admin.success", "low", "Admin login successful", ctx, nil)
secure := app.config.AdminCookieSecure
maxAge := int(app.config.SessionTTLSeconds)
ctx.SetCookie(adminSessionCookie, app.adminSessionToken(), maxAge, "/admin", "", secure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/dashboard")
}
func (app *App) handleAdminLogout(ctx *gin.Context) {
secure := app.config.AdminCookieSecure
ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", secure, true)
ctx.Redirect(http.StatusSeeOther, "/admin/login")
}
func (app *App) handleAdminDashboard(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
dashboardEnabled := config.AdminEnabledTrue
if cfgVal := app.config.AdminEnabled; cfgVal == config.AdminEnabledAuto || cfgVal == config.AdminEnabledTrue {
dashboardEnabled = cfgVal
}
dashboard := app.buildAdminDashboardView()
ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "dashboard",
"DashboardEnabled": string(dashboardEnabled),
"Dashboard": dashboard,
"AlertChipClass": adminAlertChipClass(dashboard.OpenAlerts, dashboard.HighAlerts, dashboard.MediumAlerts),
"AlertChipLabel": adminAlertChipLabel(dashboard.OpenAlerts),
})
}
func (app *App) buildAdminDashboardView() adminDashboardView {
boxes, _ := app.listAdminBoxes()
alertsList := []alerts.Alert{}
if app.alertStore != nil {
alertsList, _ = app.alertStore.List(500)
}
events := []activity.Event{}
if app.activityStore != nil {
events, _ = app.activityStore.List(80, app.config.ActivityRetentionSeconds)
}
users := []userstore.User{}
if app.userStore != nil {
users = app.userStore.List()
}
view := adminDashboardView{
StorageBackend: "local",
GuestUploadsLabel: adminBoolLabel(app.config.GuestUploadsEnabled && app.config.APIEnabled),
APIUploadsLabel: adminBoolLabel(app.config.APIEnabled),
ZipDownloadsLabel: adminBoolLabel(app.config.ZipDownloadsEnabled),
Alerts: make([]adminDashboardAlert, 0, 12),
Events: make([]adminDashboardActivity, 0, 15),
Boxes: make([]adminDashboardBox, 0, 12),
}
now := time.Now().UTC()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
usedBytes := int64(0)
for _, box := range boxes {
usedBytes += box.TotalSizeBytes
createdAt, _ := time.Parse(time.RFC3339, box.CreatedAtISO)
if !createdAt.IsZero() && createdAt.After(dayStart) {
view.BoxesCreatedToday++
}
if box.PasswordProtected {
view.PasswordedBoxes++
}
if box.Status != "expired" && box.Status != "consumed" {
view.ActiveBoxes++
}
if len(view.Boxes) < 12 {
view.Boxes = append(view.Boxes, adminDashboardBox{
ID: box.ID,
Status: box.Status,
StatusLabel: box.StatusLabel,
StatusClass: adminDashboardStatusClass(box.Status),
FileCount: box.FileCount,
CompleteFiles: box.CompleteFiles,
TotalSizeLabel: box.TotalSizeLabel,
CreatedAtLabel: box.CreatedAtLabel,
ExpiresAtLabel: box.ExpiresAtLabel,
PasswordProtected: box.PasswordProtected,
OneTimeDownload: box.OneTimeDownload,
OpenURL: box.OpenURL,
ZipURL: box.ZipURL,
Flags: box.Flags,
})
}
}
view.StorageUsedLabel = helpers.FormatBytes(usedBytes)
view.StorageCapLabel = "unknown"
view.StorageFreeLabel = "unknown"
view.StorageMeter = "0%"
if diskTotal, diskFree, ok := adminDiskCapacity(app.config.UploadsDir); ok && diskTotal > 0 {
diskUsed := diskTotal - diskFree
if diskUsed < 0 {
diskUsed = 0
}
view.StorageUsedLabel = helpers.FormatBytes(diskUsed)
view.StorageFreeLabel = helpers.FormatBytes(diskFree)
view.StorageCapLabel = helpers.FormatBytes(diskTotal)
percent := float64(diskUsed) / float64(diskTotal) * 100
if percent > 100 {
percent = 100
}
view.StorageMeter = strconv.FormatFloat(percent, 'f', 1, 64) + "%"
}
for _, alert := range alertsList {
if alert.Status != alerts.StatusClosed {
view.OpenAlerts++
switch alert.Severity {
case "high":
view.HighAlerts++
case "medium":
view.MediumAlerts++
default:
view.LowAlerts++
}
if len(view.Alerts) < 12 {
view.Alerts = append(view.Alerts, adminDashboardAlert{
ID: alert.ID,
Title: alert.Title,
Severity: adminFallback(alert.Severity, "low"),
Status: adminFallback(string(alert.Status), "open"),
Group: alert.Group,
Code: alert.Code,
Trace: alert.Trace,
Message: alert.Message,
CreatedAt: formatBrowserTime(alert.CreatedAt),
CreatedAtLabel: adminShortTimeLabel(alert.CreatedAt),
Meta: alert.Meta,
})
}
}
}
for _, event := range events {
if len(view.Events) >= 15 {
break
}
view.Events = append(view.Events, adminDashboardActivity{
ID: event.ID,
Kind: event.Kind,
Severity: adminFallback(event.Severity, "low"),
Message: event.Message,
IP: event.IP,
Path: event.Path,
Method: event.Method,
CreatedAt: formatBrowserTime(event.CreatedAt),
CreatedAtLabel: adminShortTimeLabel(event.CreatedAt),
Meta: event.Meta,
TagClass: adminSeverityTagClass(event.Severity),
TagLabel: adminActivityTagLabel(event.Kind),
})
}
for _, user := range users {
view.TotalUsers++
if user.Status == userstore.StatusDisabled {
view.DisabledUsers++
} else {
view.ActiveUsers++
}
for _, key := range user.APIKeys {
if key.RevokedAt == nil {
view.APIKeyCount++
}
}
}
return view
}
func adminBoolLabel(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
func adminFallback(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func adminShortTimeLabel(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.UTC().Format("15:04")
}
func adminDashboardStatusClass(status string) string {
switch status {
case "ready":
return "ok"
case "uploading", "legacy":
return "warn"
case "attention", "expired", "consumed":
return "danger"
default:
return "info"
}
}
func adminSeverityTagClass(severity string) string {
switch severity {
case "high":
return "danger"
case "medium":
return "warn"
case "low":
return "ok"
default:
return "info"
}
}
func adminActivityTagLabel(kind string) string {
parts := strings.Split(kind, ".")
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return "event"
}
return parts[0]
}
func adminDiskCapacity(path string) (int64, int64, bool) {
if strings.TrimSpace(path) == "" {
return 0, 0, false
}
var stats syscall.Statfs_t
if err := syscall.Statfs(path, &stats); err != nil {
return 0, 0, false
}
blockSize := int64(stats.Bsize)
if blockSize <= 0 {
return 0, 0, false
}
total := int64(stats.Blocks) * blockSize
free := int64(stats.Bavail) * blockSize
return total, free, true
}
func adminAlertChipClass(total int, high int, medium int) string {
score := high*5 + medium*2 + (total - high - medium)
switch {
case high > 0 || score >= 12:
return "is-danger"
case medium >= 2 || score >= 5:
return "is-warning"
case total > 0:
return "is-info"
default:
return "is-ok"
}
}
func adminAlertChipLabel(total int) string {
if total == 0 {
return "OK no alerts"
}
return "! " + strconv.Itoa(total) + " alerts"
}
func (app *App) handleAdminAlerts(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
alertsList := []alerts.Alert{}
if app.alertStore != nil {
var err error
alertsList, err = app.alertStore.List(500)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load alerts")
return
}
}
openCount := 0
highCount := 0
mediumCount := 0
ackedCount := 0
closedCount := 0
for _, alert := range alertsList {
switch string(alert.Status) {
case "open":
openCount++
case "acked":
ackedCount++
case "closed":
closedCount++
}
if alert.Severity == "high" && string(alert.Status) != "closed" {
highCount++
}
if alert.Severity == "medium" && string(alert.Status) != "closed" {
mediumCount++
}
}
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "alerts",
"Alerts": alertsList,
"OpenCount": strconv.Itoa(openCount),
"HighCount": strconv.Itoa(highCount),
"AckCount": strconv.Itoa(ackedCount),
"ClosedCount": strconv.Itoa(closedCount),
"AlertChipClass": adminAlertChipClass(openCount, highCount, mediumCount),
"AlertChipLabel": adminAlertChipLabel(openCount),
})
}