From cc91ce120db613eb57e5ccf84b579b3454bc6e3c Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 1 Jun 2026 03:39:45 +0300 Subject: [PATCH] feat(admin): allow editing boxes and deleting individual files Introduce new admin capabilities to manage uploaded boxes and files: - Add routes and handlers for editing boxes and deleting individual files. - Implement `RemoveFileFromBox` in `UploadService` to delete a file's stored objects and remove it from the box (deleting the box if empty). - Implement `AdminUpdateBox` in `UploadService` to update expiry, download limits, and clear password protection. - Remove the unused `AdminFiles` handler. - Add `.claude` to `.gitignore`. --- .gitignore | 2 + backend/libs/handlers/admin.go | 29 -- backend/libs/handlers/admin_files.go | 453 ++++++++++++++++++++ backend/libs/handlers/app.go | 3 + backend/libs/services/upload.go | 74 ++++ backend/static/css/16-retro.css | 6 +- backend/static/css/50-admin.css | 32 ++ backend/templates/pages/admin_box_edit.html | 131 ++++++ backend/templates/pages/admin_files.html | 107 +++++ 9 files changed, 805 insertions(+), 32 deletions(-) create mode 100644 backend/libs/handlers/admin_files.go create mode 100644 backend/templates/pages/admin_box_edit.html create mode 100644 backend/templates/pages/admin_files.html diff --git a/.gitignore b/.gitignore index 0bbe111..44fdb02 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ backend/static/uploads/* .prod.env scripts/env/dev.env docker-compose.yml + +.claude \ No newline at end of file diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index d1b0913..0ece374 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -267,35 +267,6 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) { }) } -func (a *App) AdminFiles(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 - } - boxes, err := a.adminBoxes(100) - if err != nil { - http.Error(w, "unable to load boxes", http.StatusInternalServerError) - return - } - - a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{ - Title: "Admin files", - Description: "Manage Warpbox uploads.", - CurrentUser: a.currentPublicUser(r), - Data: adminPageData{ - Stats: stats, - Boxes: boxes, - Section: "files", - PageTitle: "Admin files", - }, - }) -} - func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return diff --git a/backend/libs/handlers/admin_files.go b/backend/libs/handlers/admin_files.go new file mode 100644 index 0000000..3f0d0f3 --- /dev/null +++ b/backend/libs/handlers/admin_files.go @@ -0,0 +1,453 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "warpbox.dev/backend/libs/helpers" + "warpbox.dev/backend/libs/services" + "warpbox.dev/backend/libs/web" +) + +const adminFilesPageSize = 25 + +type adminFilesData struct { + Stats services.AdminStats + Section string + PageTitle string + Boxes []adminBoxView + Query string + Sort string + Dir string + Page int + TotalPages int + Total int + RangeFrom int + RangeTo int + Columns []adminFilesColumn + PageLinks []adminFilesPageLink + HasPrev bool + HasNext bool + PrevHref string + NextHref string +} + +type adminFilesColumn struct { + Label string + Href string + Sorted bool + Ascending bool +} + +type adminFilesPageLink struct { + Page int + Href string + Active bool +} + +type adminBoxEditData struct { + Section string + PageTitle string + Box adminBoxDetail + Files []adminBoxEditFile + Notice string + Error string +} + +type adminBoxDetail struct { + ID string + Owner string + CreatedAt string + ExpiresLabel string + ExpiresInput string + NeverExpires bool + MaxDownloads int + DownloadCount int + FileCount int + TotalSize string + BackendID string + Protected bool + Obfuscated bool +} + +type adminBoxEditFile struct { + ID string + Name string + Size string + ContentType string + ThumbnailURL string + DownloadURL string + HasPreview bool +} + +// adminFileRow is the sortable/filterable representation of a box. +type adminFileRow struct { + ID string + Owner string + CreatedAt time.Time + ExpiresAt time.Time + FileCount int + DownloadCount int + MaxDownloads int + TotalSize int64 + TotalSizeLabel string + Protected bool + Expired bool +} + +func (a *App) AdminFiles(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 + } + boxes, err := a.uploadService.AdminBoxes(0) + if err != nil { + http.Error(w, "unable to load boxes", http.StatusInternalServerError) + return + } + + ownerCache := map[string]string{} + rows := make([]adminFileRow, 0, len(boxes)) + for _, box := range boxes { + rows = append(rows, adminFileRow{ + ID: box.ID, + Owner: a.boxOwnerLabel(box.OwnerID, ownerCache), + CreatedAt: box.CreatedAt, + ExpiresAt: box.ExpiresAt, + FileCount: box.FileCount, + DownloadCount: box.DownloadCount, + MaxDownloads: box.MaxDownloads, + TotalSize: box.TotalSize, + TotalSizeLabel: box.TotalSizeLabel, + Protected: box.Protected, + Expired: box.Expired, + }) + } + + query := strings.TrimSpace(r.URL.Query().Get("q")) + if query != "" { + needle := strings.ToLower(query) + filtered := rows[:0:0] + for _, row := range rows { + if strings.Contains(strings.ToLower(row.ID), needle) || strings.Contains(strings.ToLower(row.Owner), needle) { + filtered = append(filtered, row) + } + } + rows = filtered + } + + sortKey := adminFilesSortKey(r.URL.Query().Get("sort")) + dir := r.URL.Query().Get("dir") + if dir != "asc" { + dir = "desc" + } + sortAdminFileRows(rows, sortKey, dir) + + total := len(rows) + totalPages := (total + adminFilesPageSize - 1) / adminFilesPageSize + 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) * adminFilesPageSize + if start > total { + start = total + } + end := start + adminFilesPageSize + if end > total { + end = total + } + + views := make([]adminBoxView, 0, end-start) + for _, row := range rows[start:end] { + views = append(views, adminBoxView{ + ID: row.ID, + Owner: row.Owner, + CreatedAt: row.CreatedAt.Format("Jan 2, 2006 15:04"), + ExpiresAt: boxExpiryLabel(row.ExpiresAt, "Jan 2, 2006 15:04"), + FileCount: row.FileCount, + TotalSizeLabel: row.TotalSizeLabel, + DownloadCount: row.DownloadCount, + MaxDownloads: row.MaxDownloads, + Protected: row.Protected, + Expired: row.Expired, + }) + } + + rangeFrom := 0 + if total > 0 { + rangeFrom = start + 1 + } + + a.renderPage(w, r, http.StatusOK, "admin_files.html", web.PageData{ + Title: "Admin files", + Description: "Manage Warpbox uploads.", + CurrentUser: a.currentPublicUser(r), + Data: adminFilesData{ + Stats: stats, + Section: "files", + PageTitle: "Files", + Boxes: views, + Query: query, + Sort: sortKey, + Dir: dir, + Page: page, + TotalPages: totalPages, + Total: total, + RangeFrom: rangeFrom, + RangeTo: end, + Columns: adminFilesColumns(query, sortKey, dir), + PageLinks: adminFilesPageLinks(query, sortKey, dir, page, totalPages), + HasPrev: page > 1, + HasNext: page < totalPages, + PrevHref: adminFilesHref(query, sortKey, dir, page-1), + NextHref: adminFilesHref(query, sortKey, dir, page+1), + }, + }) +} + +func (a *App) boxOwnerLabel(ownerID string, cache map[string]string) string { + if ownerID == "" { + return "Anonymous" + } + if label, ok := cache[ownerID]; ok { + return label + } + label := "User" + if user, err := a.authService.UserByID(ownerID); err == nil { + label = user.Email + } + cache[ownerID] = label + return label +} + +func adminFilesSortKey(value string) string { + switch value { + case "id", "owner", "files", "size", "downloads", "expires", "created": + return value + default: + return "created" + } +} + +func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) { + less := func(i, j int) bool { + a, b := rows[i], rows[j] + switch sortKey { + case "id": + return strings.ToLower(a.ID) < strings.ToLower(b.ID) + case "owner": + return strings.ToLower(a.Owner) < strings.ToLower(b.Owner) + case "files": + return a.FileCount < b.FileCount + case "size": + return a.TotalSize < b.TotalSize + case "downloads": + return a.DownloadCount < b.DownloadCount + case "expires": + return a.ExpiresAt.Before(b.ExpiresAt) + default: + return a.CreatedAt.Before(b.CreatedAt) + } + } + sort.SliceStable(rows, func(i, j int) bool { + if dir == "desc" { + return less(j, i) + } + return less(i, j) + }) +} + +func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn { + defs := []struct{ Key, Label string }{ + {"id", "Box"}, + {"owner", "Owner"}, + {"files", "Files"}, + {"size", "Size"}, + {"downloads", "Downloads"}, + {"created", "Created"}, + {"expires", "Expires"}, + } + columns := make([]adminFilesColumn, 0, len(defs)) + for _, def := range defs { + sorted := sortKey == def.Key + nextDir := "asc" + if sorted && dir == "asc" { + nextDir = "desc" + } + columns = append(columns, adminFilesColumn{ + Label: def.Label, + Href: adminFilesHref(query, def.Key, nextDir, 1), + Sorted: sorted, + Ascending: dir == "asc", + }) + } + return columns +} + +func adminFilesPageLinks(query, sortKey, dir string, page, totalPages int) []adminFilesPageLink { + links := make([]adminFilesPageLink, 0, 5) + const window = 2 + for p := page - window; p <= page+window; p++ { + if p < 1 || p > totalPages { + continue + } + links = append(links, adminFilesPageLink{ + Page: p, + Href: adminFilesHref(query, sortKey, dir, p), + Active: p == page, + }) + } + return links +} + +func adminFilesHref(query, sortKey, dir string, page int) string { + values := url.Values{} + if query != "" { + values.Set("q", query) + } + if sortKey != "" && sortKey != "created" { + values.Set("sort", sortKey) + } + if dir != "" && dir != "desc" { + values.Set("dir", dir) + } + if page > 1 { + values.Set("page", strconv.Itoa(page)) + } + if len(values) == 0 { + return "/admin/files" + } + return "/admin/files?" + values.Encode() +} + +func (a *App) AdminEditBox(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 + } + + var totalSize int64 + files := make([]adminBoxEditFile, 0, len(box.Files)) + for _, file := range box.Files { + totalSize += file.Size + files = append(files, adminBoxEditFile{ + ID: file.ID, + Name: file.Name, + Size: helpers.FormatBytes(file.Size), + ContentType: file.ContentType, + ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), + DownloadURL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID), + HasPreview: file.PreviewKind == "image" || file.PreviewKind == "video", + }) + } + + never := neverExpires(box.ExpiresAt) + expiresInput := "" + if !never { + expiresInput = box.ExpiresAt.UTC().Format("2006-01-02T15:04") + } + + cache := map[string]string{} + a.renderPage(w, r, http.StatusOK, "admin_box_edit.html", web.PageData{ + Title: "Edit box", + Description: "Edit a Warpbox upload.", + CurrentUser: a.currentPublicUser(r), + Data: adminBoxEditData{ + Section: "files", + PageTitle: "Edit box", + Notice: r.URL.Query().Get("notice"), + Error: r.URL.Query().Get("error"), + Files: files, + Box: adminBoxDetail{ + ID: box.ID, + Owner: a.boxOwnerLabel(box.OwnerID, cache), + CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04 MST"), + ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"), + ExpiresInput: expiresInput, + NeverExpires: never, + MaxDownloads: box.MaxDownloads, + DownloadCount: box.DownloadCount, + FileCount: len(box.Files), + TotalSize: helpers.FormatBytes(totalSize), + BackendID: a.uploadService.BoxStorageBackendID(box), + Protected: a.uploadService.IsProtected(box), + Obfuscated: box.Obfuscate, + }, + }, + }) +} + +func (a *App) AdminUpdateBox(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + boxID := r.PathValue("boxID") + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+read+form", http.StatusSeeOther) + return + } + + var expiresAt time.Time + if r.FormValue("never_expires") == "on" { + expiresAt = time.Now().UTC().AddDate(100, 0, 0) + } else { + parsed, err := time.Parse("2006-01-02T15:04", strings.TrimSpace(r.FormValue("expires_at"))) + if err != nil { + http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Invalid+expiration+date", http.StatusSeeOther) + return + } + expiresAt = parsed.UTC() + } + + maxDownloads := parsePositiveInt(r.FormValue("max_downloads")) + removePassword := r.FormValue("remove_password") == "on" + + if err := a.uploadService.AdminUpdateBox(boxID, expiresAt, maxDownloads, removePassword); err != nil { + a.logger.Warn("admin box update failed", "source", "admin", "severity", "warn", "code", 4306, "box_id", boxID, "error", err.Error()) + http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+save+changes", http.StatusSeeOther) + return + } + a.logger.Info("admin box updated", "source", "admin", "severity", "user_activity", "code", 2306, "ip", uploadClientIP(r), "box_id", boxID) + http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=Changes+saved", http.StatusSeeOther) +} + +func (a *App) AdminDeleteBoxFile(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) { + return + } + boxID := r.PathValue("boxID") + fileID := r.PathValue("fileID") + boxDeleted, err := a.uploadService.RemoveFileFromBox(boxID, fileID) + if err != nil { + a.logger.Warn("admin file delete failed", "source", "admin", "severity", "warn", "code", 4305, "box_id", boxID, "file_id", fileID, "error", err.Error()) + http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?error=Could+not+remove+file", http.StatusSeeOther) + return + } + a.logger.Info("admin removed box file", "source", "admin", "severity", "user_activity", "code", 2305, "ip", uploadClientIP(r), "box_id", boxID, "file_id", fileID) + if boxDeleted { + http.Redirect(w, r, "/admin/files?notice=Box+deleted+(last+file+removed)", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/boxes/"+boxID+"/edit?notice=File+removed", http.StatusSeeOther) +} diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index c353f1f..f0257cd 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -108,6 +108,9 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /admin/users/{userID}/policy", a.AdminUpdateUserPolicy) mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage) mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) + mux.HandleFunc("GET /admin/boxes/{boxID}/edit", a.AdminEditBox) + mux.HandleFunc("POST /admin/boxes/{boxID}/edit", a.AdminUpdateBox) + mux.HandleFunc("POST /admin/boxes/{boxID}/files/{fileID}/delete", a.AdminDeleteBoxFile) mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted) diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 23c9bb1..6f7219c 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -613,6 +613,80 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error { return nil } +// RemoveFileFromBox deletes a single file's stored objects (and thumbnail) and +// removes it from the box. If it was the box's last file, the whole box is +// deleted. Returns whether the box itself was removed. +func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) { + box, err := s.GetBox(boxID) + if err != nil { + return false, err + } + index := -1 + for i, file := range box.Files { + if file.ID == fileID { + index = i + break + } + } + if index < 0 { + return false, os.ErrNotExist + } + file := box.Files[index] + + backendID := s.BoxStorageBackendID(box) + backend, err := s.storage.Backend(backendID) + if err != nil { + backend, err = s.storage.BackendForMaintenance(backendID) + } + if err == nil { + if key := s.FileObjectKey(box, file); key != "" { + _ = backend.Delete(context.Background(), key) + } + if key := s.ThumbnailObjectKey(box, file); key != "" { + _ = backend.Delete(context.Background(), key) + } + } + + box.Files = append(box.Files[:index], box.Files[index+1:]...) + if len(box.Files) == 0 { + if err := s.DeleteBoxWithSource(box.ID, "admin"); err != nil { + return false, err + } + return true, nil + } + if err := s.SaveBox(box); err != nil { + return false, err + } + s.logger.Info("admin removed file", "source", "admin", "severity", "user_activity", "code", 2305, "box_id", box.ID, "file_id", fileID) + return false, nil +} + +// AdminUpdateBox lets an admin change a box's expiry, download limit, and +// optionally clear password protection. +func (s *UploadService) AdminUpdateBox(boxID string, expiresAt time.Time, maxDownloads int, removePassword bool) error { + box, err := s.GetBox(boxID) + if err != nil { + return err + } + if !expiresAt.IsZero() { + box.ExpiresAt = expiresAt.UTC() + } + if maxDownloads < 0 { + maxDownloads = 0 + } + box.MaxDownloads = maxDownloads + if removePassword { + box.PasswordHash = "" + box.PasswordSalt = "" + box.Obfuscate = false + } + if err := s.SaveBox(box); err != nil { + return err + } + s.logger.Info("admin updated box", "source", "admin", "severity", "user_activity", "code", 2306, "box_id", box.ID) + return nil +} + func (s *UploadService) FindFile(box Box, fileID string) (File, error) { for _, file := range box.Files { if file.ID == fileID { diff --git a/backend/static/css/16-retro.css b/backend/static/css/16-retro.css index 923775d..8238b5e 100644 --- a/backend/static/css/16-retro.css +++ b/backend/static/css/16-retro.css @@ -152,16 +152,16 @@ /* Links: classic blue, underlined, purple when visited. Sidebar links and tabs are styled as their own Win98 controls below, so they're excluded here. */ -:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab) { +:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link) { color: #0000ee; text-decoration: underline; } -:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):visited { +:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited { color: #551a8b; } -:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):hover { +:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):hover { color: #ee0000; } diff --git a/backend/static/css/50-admin.css b/backend/static/css/50-admin.css index 2a59824..679f609 100644 --- a/backend/static/css/50-admin.css +++ b/backend/static/css/50-admin.css @@ -106,6 +106,38 @@ font-weight: 650; } +.sort-link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: var(--muted-foreground); + font-weight: 650; + text-decoration: none; +} + +.sort-link:hover, +.sort-link.is-sorted { + color: var(--foreground); +} + +.sort-arrow { + font-size: 0.7rem; +} + +.pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-top: 1rem; +} + +.pagination-summary { + margin: 0.6rem 0 0; + color: var(--muted-foreground); + font-size: 0.78rem; +} + .table-actions { display: flex; align-items: flex-start; diff --git a/backend/templates/pages/admin_box_edit.html b/backend/templates/pages/admin_box_edit.html new file mode 100644 index 0000000..5e78194 --- /dev/null +++ b/backend/templates/pages/admin_box_edit.html @@ -0,0 +1,131 @@ +{{define "admin_box_edit.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+ + +
+
+
+

Operator console · Files

+

{{.Data.PageTitle}}

+

Box {{.Data.Box.ID}} · {{.Data.Box.Owner}}

+
+ Open box +
+ + {{if .Data.Notice}}

{{.Data.Notice}}

{{end}} + {{if .Data.Error}}

{{.Data.Error}}

{{end}} + +
+
+
+
+

Box settings

+

Change expiration, download limit, and protection.

+
+
+ +
+
Created
{{.Data.Box.CreatedAt}}
+
Files
{{.Data.Box.FileCount}}
+
Total size
{{.Data.Box.TotalSize}}
+
Downloads
{{.Data.Box.DownloadCount}}{{if .Data.Box.MaxDownloads}} / {{.Data.Box.MaxDownloads}}{{end}}
+
Expires
{{.Data.Box.ExpiresLabel}}
+
Storage backend
{{.Data.Box.BackendID}}
+
Protected
{{if .Data.Box.Protected}}Yes{{else}}No{{end}}
+
+ +
+ + + + + {{if .Data.Box.Protected}} + + {{end}} + +
+
+
+ +
+
+
+
+

Files

+

Remove individual files from this box. Removing the last file deletes the box.

+
+
+ +
+ {{range .Data.Files}} + + {{else}} +

This box has no files.

+ {{end}} +
+
+
+ +
+
+
+
+

Danger zone

+

Permanently delete this box and all of its files.

+
+
+ + +
+
+
+
+
+
+{{end}} diff --git a/backend/templates/pages/admin_files.html b/backend/templates/pages/admin_files.html new file mode 100644 index 0000000..d79019c --- /dev/null +++ b/backend/templates/pages/admin_files.html @@ -0,0 +1,107 @@ +{{define "admin_files.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+ + +
+
+
+

Operator console

+

{{.Data.PageTitle}}

+

{{.Data.Total}} box{{if ne .Data.Total 1}}es{{end}} total.

+
+
+ +
+
+
+
+

All uploads

+

Search, sort, and manage every box.

+
+
+ + + + + {{if .Data.Query}}Clear{{end}} +
+
+ +
+ + + + {{range .Data.Columns}} + + {{end}} + + + + + + {{range .Data.Boxes}} + + + + + + + + + + + + {{else}} + + {{end}} + +
{{.Label}}{{if .Sorted}}{{end}}StatusActions
{{.ID}}{{.Owner}}{{.FileCount}}{{.TotalSizeLabel}}{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}{{.CreatedAt}}{{.ExpiresAt}} + {{if .Expired}}expired{{else}}active{{end}} + {{if .Protected}}protected{{end}} + + Edit + View +
+ + +
+
No boxes match.
+
+ + {{if gt .Data.TotalPages 1}} + + {{end}} +

Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}

+
+
+
+
+{{end}}