All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Enhance the admin panel by introducing visual overview charts for upload and storage trends, along with status bars for system metrics. Additionally, implement pagination for the admin logs view, allowing users to navigate through log entries with configurable page sizes. Corresponding CSS styles have been added for the new charts, metrics grid, and pagination controls.
493 lines
13 KiB
Go
493 lines
13 KiB
Go
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)
|
|
}
|