Files
warpbox/lib/server/admin.go

498 lines
15 KiB
Go
Raw Normal View History

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),
})
}