package server import ( "crypto/subtle" "errors" "fmt" "net/http" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "warpbox/lib/boxstore" "warpbox/lib/config" "warpbox/lib/helpers" "warpbox/lib/metastore" ) const adminSessionCookie = "warpbox_admin_session" type adminUserRow struct { ID string Username string Email string Tags string CreatedAt string Disabled bool IsCurrent bool } type adminTagRow struct { ID string Name string Description string Protected bool AdminAccess bool UploadAllowed bool ZipDownloadAllowed bool OneTimeDownloadAllowed bool RenewableAllowed bool MaxFileSizeBytes string MaxBoxSizeBytes string AllowedExpirySeconds string } type adminBoxRow struct { ID string FileCount int TotalSizeLabel string CreatedAt string ExpiresAt string Expired bool OneTimeDownload bool PasswordProtected bool } func (app *App) registerAdminRoutes(router *gin.Engine) { admin := router.Group("/admin") admin.Use(noStoreAdminHeaders) admin.GET("/login", app.handleAdminLogin) admin.POST("/login", app.handleAdminLoginPost) protected := admin.Group("") protected.Use(app.requireAdminSession) protected.POST("/logout", app.handleAdminLogout) protected.GET("", app.handleAdminDashboard) protected.GET("/", app.handleAdminDashboard) protected.GET("/boxes", app.handleAdminBoxes) protected.GET("/users", app.handleAdminUsers) protected.POST("/users", app.handleAdminUsersPost) protected.GET("/tags", app.handleAdminTags) protected.POST("/tags", app.handleAdminTagsPost) protected.GET("/settings", app.handleAdminSettings) protected.POST("/settings", app.handleAdminSettingsPost) } func (app *App) handleAdminLogin(ctx *gin.Context) { if app.isAdminSessionValid(ctx) { ctx.Redirect(http.StatusSeeOther, "/admin") return } app.renderAdminLogin(ctx, "") } func (app *App) handleAdminLoginPost(ctx *gin.Context) { if !app.adminLoginEnabled { app.renderAdminLogin(ctx, "Administrator login is disabled.") return } username := strings.TrimSpace(ctx.PostForm("username")) password := ctx.PostForm("password") user, ok, err := app.store.GetUserByUsername(username) if err != nil { ctx.String(http.StatusInternalServerError, "Could not load user") return } if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) { app.renderAdminLogin(ctx, "The username or password was not accepted.") return } perms, err := app.permissionsForUser(user) if err != nil { ctx.String(http.StatusInternalServerError, "Could not load permissions") return } if !perms.AdminAccess { app.renderAdminLogin(ctx, "This user does not have administrator access.") return } session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second) if err != nil { ctx.String(http.StatusInternalServerError, "Could not create session") return } ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetCookie(adminSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/admin", "", app.config.AdminCookieSecure, true) ctx.Redirect(http.StatusSeeOther, "/admin") } func (app *App) handleAdminLogout(ctx *gin.Context) { if token, err := ctx.Cookie(adminSessionCookie); err == nil { _ = app.store.DeleteSession(token) } ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", app.config.AdminCookieSecure, true) ctx.Redirect(http.StatusSeeOther, "/admin/login") } func (app *App) handleAdminDashboard(ctx *gin.Context) { ctx.HTML(http.StatusOK, "admin.html", gin.H{ "CurrentUser": app.currentAdminUsername(ctx), "CSRFToken": app.currentCSRFToken(ctx), }) } func (app *App) handleAdminBoxes(ctx *gin.Context) { if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminBoxesView }) { return } summaries, err := boxstore.ListBoxSummaries() if err != nil { ctx.String(http.StatusInternalServerError, "Could not list boxes") return } rows := make([]adminBoxRow, 0, len(summaries)) totalSize := int64(0) expiredCount := 0 for _, summary := range summaries { totalSize += summary.TotalSize if summary.Expired { expiredCount++ } rows = append(rows, adminBoxRow{ ID: summary.ID, FileCount: summary.FileCount, TotalSizeLabel: summary.TotalSizeLabel, CreatedAt: formatAdminTime(summary.CreatedAt), ExpiresAt: formatAdminTime(summary.ExpiresAt), Expired: summary.Expired, OneTimeDownload: summary.OneTimeDownload, PasswordProtected: summary.PasswordProtected, }) } ctx.HTML(http.StatusOK, "admin_boxes.html", gin.H{ "CurrentUser": app.currentAdminUsername(ctx), "Boxes": rows, "TotalBoxes": len(rows), "TotalStorage": helpers.FormatBytes(totalSize), "ExpiredBoxes": expiredCount, }) } func (app *App) handleAdminUsers(ctx *gin.Context) { if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) { return } app.renderAdminUsers(ctx, "") } func (app *App) handleAdminUsersPost(ctx *gin.Context) { if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) { return } if ctx.PostForm("action") == "toggle_disabled" { userID := strings.TrimSpace(ctx.PostForm("user_id")) user, ok, err := app.store.GetUser(userID) if err != nil || !ok { app.renderAdminUsers(ctx, "User not found.") return } if current, ok := ctx.Get("adminUser"); ok { if currentUser, ok := current.(metastore.User); ok && currentUser.ID == user.ID { app.renderAdminUsers(ctx, "You cannot disable the user for the active session.") return } } user.Disabled = !user.Disabled if err := app.store.UpdateUser(user); err != nil { app.renderAdminUsers(ctx, err.Error()) return } ctx.Redirect(http.StatusSeeOther, "/admin/users") return } username := ctx.PostForm("username") email := ctx.PostForm("email") password := ctx.PostForm("password") tagIDs := ctx.PostFormArray("tag_ids") if _, err := app.store.CreateUserWithPassword(username, email, password, tagIDs); err != nil { app.renderAdminUsers(ctx, err.Error()) return } ctx.Redirect(http.StatusSeeOther, "/admin/users") } func (app *App) renderAdminUsers(ctx *gin.Context, errorMessage string) { users, err := app.store.ListUsers() if err != nil { ctx.String(http.StatusInternalServerError, "Could not list users") return } tags, err := app.store.ListTags() if err != nil { ctx.String(http.StatusInternalServerError, "Could not list tags") return } tagNames := make(map[string]string, len(tags)) for _, tag := range tags { tagNames[tag.ID] = tag.Name } sort.Slice(users, func(i int, j int) bool { return strings.ToLower(users[i].Username) < strings.ToLower(users[j].Username) }) currentID := "" if current, ok := ctx.Get("adminUser"); ok { if currentUser, ok := current.(metastore.User); ok { currentID = currentUser.ID } } rows := make([]adminUserRow, 0, len(users)) for _, user := range users { names := make([]string, 0, len(user.TagIDs)) for _, tagID := range user.TagIDs { if name := tagNames[tagID]; name != "" { names = append(names, name) } } rows = append(rows, adminUserRow{ ID: user.ID, Username: user.Username, Email: user.Email, Tags: strings.Join(names, ", "), CreatedAt: formatAdminTime(user.CreatedAt), Disabled: user.Disabled, IsCurrent: user.ID == currentID, }) } ctx.HTML(http.StatusOK, "admin_users.html", gin.H{ "CurrentUser": app.currentAdminUsername(ctx), "CSRFToken": app.currentCSRFToken(ctx), "Users": rows, "Tags": tags, "Error": errorMessage, }) } func (app *App) handleAdminTags(ctx *gin.Context) { if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) { return } app.renderAdminTags(ctx, "") } func (app *App) handleAdminTagsPost(ctx *gin.Context) { if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminUsersManage }) { return } perms, err := parseTagPermissions(ctx) if err != nil { app.renderAdminTags(ctx, err.Error()) return } tag := metastore.Tag{ Name: ctx.PostForm("name"), Description: ctx.PostForm("description"), Permissions: perms, } if err := app.store.CreateTag(&tag); err != nil { app.renderAdminTags(ctx, err.Error()) return } ctx.Redirect(http.StatusSeeOther, "/admin/tags") } func (app *App) renderAdminTags(ctx *gin.Context, errorMessage string) { tags, err := app.store.ListTags() if err != nil { ctx.String(http.StatusInternalServerError, "Could not list tags") return } sort.Slice(tags, func(i int, j int) bool { return strings.ToLower(tags[i].Name) < strings.ToLower(tags[j].Name) }) rows := make([]adminTagRow, 0, len(tags)) for _, tag := range tags { rows = append(rows, adminTagRow{ ID: tag.ID, Name: tag.Name, Description: tag.Description, Protected: tag.Protected, AdminAccess: tag.Permissions.AdminAccess, UploadAllowed: tag.Permissions.UploadAllowed, ZipDownloadAllowed: tag.Permissions.ZipDownloadAllowed, OneTimeDownloadAllowed: tag.Permissions.OneTimeDownloadAllowed, RenewableAllowed: tag.Permissions.RenewableAllowed, MaxFileSizeBytes: optionalInt64Label(tag.Permissions.MaxFileSizeBytes), MaxBoxSizeBytes: optionalInt64Label(tag.Permissions.MaxBoxSizeBytes), AllowedExpirySeconds: joinInt64s(tag.Permissions.AllowedExpirySeconds), }) } ctx.HTML(http.StatusOK, "admin_tags.html", gin.H{ "CurrentUser": app.currentAdminUsername(ctx), "CSRFToken": app.currentCSRFToken(ctx), "Tags": rows, "Error": errorMessage, }) } func (app *App) handleAdminSettings(ctx *gin.Context) { if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) { return } app.renderAdminSettings(ctx, "") } func (app *App) handleAdminSettingsPost(ctx *gin.Context) { if !app.requireAdminFlag(ctx, func(perms metastore.EffectivePermissions) bool { return perms.AdminSettingsManage }) { return } if !app.config.AllowAdminSettingsOverride { app.renderAdminSettings(ctx, "Admin settings overrides are disabled by environment configuration.") return } for _, def := range config.EditableDefinitions() { value := ctx.PostForm(def.Key) if def.Type == config.SettingTypeBool { value = "false" if ctx.PostForm(def.Key) == "true" { value = "true" } } if err := app.config.ApplyOverride(def.Key, value); err != nil { app.renderAdminSettings(ctx, err.Error()) return } if err := app.store.SetSetting(def.Key, value); err != nil { app.renderAdminSettings(ctx, err.Error()) return } } applyBoxstoreRuntimeConfig(app.config) ctx.Redirect(http.StatusSeeOther, "/admin/settings") } func (app *App) renderAdminSettings(ctx *gin.Context, errorMessage string) { ctx.HTML(http.StatusOK, "admin_settings.html", gin.H{ "CurrentUser": app.currentAdminUsername(ctx), "CSRFToken": app.currentCSRFToken(ctx), "Rows": app.config.SettingRows(), "OverridesAllowed": app.config.AllowAdminSettingsOverride, "Error": errorMessage, }) } func (app *App) requireAdminSession(ctx *gin.Context) { token, err := ctx.Cookie(adminSessionCookie) if err != nil { ctx.Redirect(http.StatusSeeOther, "/admin/login") ctx.Abort() return } session, ok, err := app.store.GetSession(token) if err != nil || !ok { ctx.Redirect(http.StatusSeeOther, "/admin/login") ctx.Abort() return } if !validAdminCSRF(ctx, session) { ctx.String(http.StatusForbidden, "Permission denied") ctx.Abort() return } user, ok, err := app.store.GetUser(session.UserID) if err != nil || !ok || user.Disabled { ctx.Redirect(http.StatusSeeOther, "/admin/login") ctx.Abort() return } perms, err := app.permissionsForUser(user) if err != nil || !perms.AdminAccess { ctx.Redirect(http.StatusSeeOther, "/admin/login") ctx.Abort() return } ctx.Set("adminUser", user) ctx.Set("adminPerms", perms) ctx.Set("adminCSRFToken", session.CSRFToken) ctx.Next() } func (app *App) isAdminSessionValid(ctx *gin.Context) bool { token, err := ctx.Cookie(adminSessionCookie) if err != nil { return false } session, ok, err := app.store.GetSession(token) if err != nil || !ok { return false } user, ok, err := app.store.GetUser(session.UserID) if err != nil || !ok || user.Disabled { return false } perms, err := app.permissionsForUser(user) return err == nil && perms.AdminAccess } func (app *App) permissionsForUser(user metastore.User) (metastore.EffectivePermissions, error) { tags, err := app.store.TagsByID(user.TagIDs) if err != nil { return metastore.EffectivePermissions{}, err } return metastore.ResolveUserPermissions(app.config, user, tags), nil } func (app *App) requireAdminFlag(ctx *gin.Context, allowed func(metastore.EffectivePermissions) bool) bool { value, ok := ctx.Get("adminPerms") if !ok { ctx.String(http.StatusForbidden, "Permission denied") return false } perms, ok := value.(metastore.EffectivePermissions) if !ok || !allowed(perms) { ctx.String(http.StatusForbidden, "Permission denied") return false } return true } func (app *App) currentAdminUsername(ctx *gin.Context) string { if current, ok := ctx.Get("adminUser"); ok { if user, ok := current.(metastore.User); ok { return user.Username } } return "" } func (app *App) currentCSRFToken(ctx *gin.Context) string { if value, ok := ctx.Get("adminCSRFToken"); ok { if token, ok := value.(string); ok { return token } } return "" } func (app *App) renderAdminLogin(ctx *gin.Context, errorMessage string) { ctx.HTML(http.StatusOK, "admin_login.html", gin.H{ "AdminLoginEnabled": app.adminLoginEnabled, "Error": errorMessage, }) } func noStoreAdminHeaders(ctx *gin.Context) { ctx.Header("Cache-Control", "no-store") ctx.Header("Pragma", "no-cache") ctx.Header("X-Content-Type-Options", "nosniff") ctx.Next() } func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool { switch ctx.Request.Method { case http.MethodGet, http.MethodHead, http.MethodOptions: return true } token := ctx.PostForm("csrf_token") return token != "" && subtleConstantTimeEqual(token, session.CSRFToken) } func subtleConstantTimeEqual(a string, b string) bool { if len(a) != len(b) { return false } return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } func parseTagPermissions(ctx *gin.Context) (metastore.TagPermissions, error) { maxFileSize, err := parseOptionalInt64(ctx.PostForm("max_file_size_bytes")) if err != nil { return metastore.TagPermissions{}, fmt.Errorf("max file size bytes %w", err) } maxBoxSize, err := parseOptionalInt64(ctx.PostForm("max_box_size_bytes")) if err != nil { return metastore.TagPermissions{}, fmt.Errorf("max box size bytes %w", err) } expirySeconds, err := parseCSVInt64(ctx.PostForm("allowed_expiry_seconds")) if err != nil { return metastore.TagPermissions{}, err } return metastore.TagPermissions{ UploadAllowed: checkbox(ctx, "upload_allowed"), AllowedExpirySeconds: expirySeconds, MaxFileSizeBytes: maxFileSize, MaxBoxSizeBytes: maxBoxSize, OneTimeDownloadAllowed: checkbox(ctx, "one_time_download_allowed"), ZipDownloadAllowed: checkbox(ctx, "zip_download_allowed"), RenewableAllowed: checkbox(ctx, "renewable_allowed"), AdminAccess: checkbox(ctx, "admin_access"), AdminUsersManage: checkbox(ctx, "admin_users_manage"), AdminSettingsManage: checkbox(ctx, "admin_settings_manage"), AdminBoxesView: checkbox(ctx, "admin_boxes_view"), }, nil } func checkbox(ctx *gin.Context, name string) bool { return ctx.PostForm(name) == "true" } func parseOptionalInt64(raw string) (*int64, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } value, err := strconv.ParseInt(raw, 10, 64) if err != nil { return nil, errors.New("must be an integer") } if value < 0 { return nil, errors.New("must be at least 0") } return &value, nil } func parseCSVInt64(raw string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } parts := strings.Split(raw, ",") values := make([]int64, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } value, err := strconv.ParseInt(part, 10, 64) if err != nil { return nil, fmt.Errorf("allowed expiry durations must be comma-separated seconds") } if value < 0 { return nil, fmt.Errorf("allowed expiry durations must be at least 0") } values = append(values, value) } return values, nil } func optionalInt64Label(value *int64) string { if value == nil { return "-" } return strconv.FormatInt(*value, 10) } func joinInt64s(values []int64) string { if len(values) == 0 { return "-" } parts := make([]string, 0, len(values)) for _, value := range values { parts = append(parts, strconv.FormatInt(value, 10)) } return strings.Join(parts, ", ") } func formatAdminTime(value time.Time) string { if value.IsZero() { return "-" } return value.Local().Format("2006-01-02 15:04:05") }