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 adminFilesDefaultPageSize = 50 var adminFilesPageSizes = []int{25, 50, 100, 200} type adminFilesData struct { Stats services.AdminStats Section string PageTitle string Boxes []adminBoxView Query string Sort string Dir string Page int PerPage int PerPageOptions []int TotalPages int Total int RangeFrom int RangeTo int Columns []adminFilesColumn PageLinks []adminFilesPageLink HasPrev bool HasNext bool PrevHref string NextHref string } // adminFilesQuery captures the listing state that every paginated link must // preserve. type adminFilesQuery struct { Query string Sort string Dir string Per int } 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) perPage := normalizePageSize(r.URL.Query().Get("per"), adminFilesDefaultPageSize, adminFilesPageSizes) state := adminFilesQuery{Query: query, Sort: sortKey, Dir: dir, Per: perPage} total := len(rows) totalPages := (total + perPage - 1) / perPage 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) * perPage if start > total { start = total } end := start + perPage 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, PerPage: perPage, PerPageOptions: adminFilesPageSizes, TotalPages: totalPages, Total: total, RangeFrom: rangeFrom, RangeTo: end, Columns: adminFilesColumns(state, sortKey, dir), PageLinks: adminFilesPageLinks(state, page, totalPages), HasPrev: page > 1, HasNext: page < totalPages, PrevHref: adminFilesHref(state, page-1), NextHref: adminFilesHref(state, 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(state adminFilesQuery, 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" } colState := state colState.Sort = def.Key colState.Dir = nextDir columns = append(columns, adminFilesColumn{ Label: def.Label, Href: adminFilesHref(colState, 1), Sorted: sorted, Ascending: dir == "asc", }) } return columns } func adminFilesPageLinks(state adminFilesQuery, 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(state, p), Active: p == page, }) } return links } func adminFilesHref(state adminFilesQuery, page int) string { values := url.Values{} if state.Query != "" { values.Set("q", state.Query) } if state.Sort != "" && state.Sort != "created" { values.Set("sort", state.Sort) } if state.Dir != "" && state.Dir != "desc" { values.Set("dir", state.Dir) } if state.Per > 0 && state.Per != adminFilesDefaultPageSize { values.Set("per", strconv.Itoa(state.Per)) } if page > 1 { values.Set("page", strconv.Itoa(page)) } if len(values) == 0 { return "/admin/files" } return "/admin/files?" + values.Encode() } // normalizePageSize parses a requested page size, falling back to def when the // value is missing or not one of the allowed sizes. func normalizePageSize(raw string, def int, allowed []int) int { parsed, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil { return def } for _, size := range allowed { if size == parsed { return parsed } } return def } 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) }