package server import ( "fmt" "net/http" "sort" "strings" "time" "github.com/gin-gonic/gin" "warpbox/lib/boxstore" ) type adminBoxesActionRequest struct { Action string `json:"action"` BoxIDs []string `json:"box_ids"` DeltaSeconds int64 `json:"delta_seconds,omitempty"` } type adminBoxFileView struct { Name string `json:"name"` SizeLabel string `json:"size_label"` MimeType string `json:"mime_type"` Status string `json:"status"` StatusLabel string `json:"status_label"` DownloadPath string `json:"download_path"` ThumbnailURL string `json:"thumbnail_url"` IsComplete bool `json:"is_complete"` } type adminBoxView struct { ID string `json:"id"` Status string `json:"status"` StatusLabel string `json:"status_label"` FileCount int `json:"file_count"` CompleteFiles int `json:"complete_files"` PendingFiles int `json:"pending_files"` FailedFiles int `json:"failed_files"` TotalSizeLabel string `json:"total_size_label"` CreatedAtLabel string `json:"created_at_label"` CreatedAtISO string `json:"created_at_iso"` ExpiresAtLabel string `json:"expires_at_label"` ExpiresAtISO string `json:"expires_at_iso"` RetentionLabel string `json:"retention_label"` PasswordProtected bool `json:"password_protected"` OneTimeDownload bool `json:"one_time_download"` ZipDisabled bool `json:"zip_disabled"` ZipAvailable bool `json:"zip_available"` Consumed bool `json:"consumed"` HasManifest bool `json:"has_manifest"` OpenURL string `json:"open_url"` ZipURL string `json:"zip_url"` Flags []string `json:"flags"` Files []adminBoxFileView `json:"files"` SearchText string `json:"search_text"` } func (app *App) handleAdminBoxes(ctx *gin.Context) { if !app.adminLoginEnabled() { ctx.Redirect(http.StatusSeeOther, "/") return } boxes, err := app.listAdminBoxes() if err != nil { ctx.String(http.StatusInternalServerError, "Could not load boxes") return } ctx.HTML(http.StatusOK, "admin/boxes.html", gin.H{ "AdminUsername": app.config.AdminUsername, "AdminEmail": app.config.AdminEmail, "ActivePage": "boxes", "Boxes": boxes, "ZipDownloadsOn": app.config.ZipDownloadsEnabled, }) } func (app *App) handleAdminBoxesAction(ctx *gin.Context) { var request adminBoxesActionRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"}) return } if len(request.BoxIDs) == 0 { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"}) return } switch request.Action { case "delete", "expire", "bump": default: ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"}) return } if request.Action == "bump" && request.DeltaSeconds <= 0 { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"}) return } processed := 0 warnings := make([]string, 0) for _, boxID := range request.BoxIDs { if !boxstore.ValidBoxID(boxID) { warnings = append(warnings, fmt.Sprintf("%s: invalid box id", boxID)) continue } var err error switch request.Action { case "delete": err = boxstore.DeleteBox(boxID) case "expire": _, err = boxstore.ExpireBox(boxID) case "bump": _, err = boxstore.BumpBoxExpiry(boxID, time.Duration(request.DeltaSeconds)*time.Second) } if err != nil { warnings = append(warnings, fmt.Sprintf("%s: %v", boxID, err)) continue } processed++ } boxes, err := app.listAdminBoxes() if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Action finished, but boxes could not be reloaded"}) return } status := http.StatusOK if processed == 0 && len(warnings) > 0 { status = http.StatusBadRequest } ctx.JSON(status, gin.H{ "ok": len(warnings) == 0, "message": adminBoxesActionMessage(request.Action, processed, request.DeltaSeconds), "warnings": warnings, "boxes": boxes, }) } func (app *App) listAdminBoxes() ([]adminBoxView, error) { summaries, err := boxstore.ListBoxSummaries() if err != nil { return nil, err } boxes := make([]adminBoxView, 0, len(summaries)) for _, summary := range summaries { boxView, err := app.buildAdminBoxView(summary.ID) if err != nil { continue } boxes = append(boxes, boxView) } sort.Slice(boxes, func(i, j int) bool { return boxes[i].CreatedAtISO > boxes[j].CreatedAtISO }) return boxes, nil } func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) { summary, err := boxstore.BoxSummary(boxID) if err != nil { return adminBoxView{}, err } files, err := boxstore.ListFiles(boxID) if err != nil { return adminBoxView{}, err } manifest, manifestErr := boxstore.ReadManifest(boxID) hasManifest := manifestErr == nil boxView := adminBoxView{ ID: summary.ID, FileCount: summary.FileCount, TotalSizeLabel: summary.TotalSizeLabel, CreatedAtLabel: adminTimeLabel(summary.CreatedAt), CreatedAtISO: formatBrowserTime(summary.CreatedAt), ExpiresAtLabel: "Not set", ExpiresAtISO: formatBrowserTime(summary.ExpiresAt), RetentionLabel: "Legacy / unmanaged", PasswordProtected: summary.PasswordProtected, OneTimeDownload: summary.OneTimeDownload, HasManifest: hasManifest, OpenURL: "/box/" + summary.ID, Files: make([]adminBoxFileView, 0, len(files)), } if !summary.ExpiresAt.IsZero() { boxView.ExpiresAtLabel = adminTimeLabel(summary.ExpiresAt) } searchParts := []string{summary.ID, summary.TotalSizeLabel} for _, file := range files { if file.IsComplete { boxView.CompleteFiles++ } if file.Status == "failed" { boxView.FailedFiles++ } if !file.IsComplete && file.Status != "failed" { boxView.PendingFiles++ } boxView.Files = append(boxView.Files, adminBoxFileView{ Name: file.Name, SizeLabel: file.SizeLabel, MimeType: file.MimeType, Status: file.Status, StatusLabel: file.StatusLabel, DownloadPath: file.DownloadPath, ThumbnailURL: file.ThumbnailURL, IsComplete: file.IsComplete, }) searchParts = append(searchParts, file.Name, file.MimeType, file.StatusLabel) } if hasManifest { boxView.RetentionLabel = manifest.RetentionLabel boxView.ZipDisabled = manifest.DisableZip boxView.Consumed = manifest.Consumed } else { boxView.ZipDisabled = false } boxView.ZipAvailable = app.config.ZipDownloadsEnabled && !boxView.ZipDisabled && !boxView.Consumed && boxView.FileCount > 0 && boxView.PendingFiles == 0 if boxView.ZipAvailable { boxView.ZipURL = "/box/" + summary.ID + "/download" } boxView.Status, boxView.StatusLabel = deriveAdminBoxStatus(hasManifest, summary.Expired, boxView.PendingFiles, boxView.FailedFiles, boxView.Consumed) boxView.Flags = deriveAdminBoxFlags(boxView) searchParts = append(searchParts, boxView.StatusLabel, boxView.RetentionLabel) boxView.SearchText = strings.ToLower(strings.Join(searchParts, " ")) return boxView, nil } func deriveAdminBoxStatus(hasManifest bool, expired bool, pendingFiles int, failedFiles int, consumed bool) (string, string) { switch { case !hasManifest: return "legacy", "Legacy" case consumed: return "consumed", "Consumed" case expired: return "expired", "Expired" case pendingFiles > 0: return "uploading", "Uploading" case failedFiles > 0: return "attention", "Needs review" default: return "ready", "Ready" } } func deriveAdminBoxFlags(box adminBoxView) []string { flags := make([]string, 0, 5) if box.PasswordProtected { flags = append(flags, "protected") } if box.OneTimeDownload { flags = append(flags, "one-time") } if box.ZipDisabled { flags = append(flags, "zip off") } if !box.HasManifest { flags = append(flags, "legacy") } if box.Consumed { flags = append(flags, "consumed") } return flags } func adminTimeLabel(value time.Time) string { if value.IsZero() { return "Not set" } return value.UTC().Format("2006-01-02 15:04 UTC") } func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) string { switch action { case "delete": return fmt.Sprintf("Deleted %d box(es)", processed) case "expire": return fmt.Sprintf("Expired %d box(es)", processed) case "bump": return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds)) default: return "Action complete" } } func adminBoxesDeltaLabel(deltaSeconds int64) string { switch deltaSeconds { case 24 * 60 * 60: return "24h" case 7 * 24 * 60 * 60: return "7d" default: return (time.Duration(deltaSeconds) * time.Second).String() } }