Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60d2ea0204 | |||
| ffa2d9636b | |||
| cc91ce120d |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,3 +15,5 @@ backend/static/uploads/*
|
||||
.prod.env
|
||||
scripts/env/dev.env
|
||||
docker-compose.yml
|
||||
|
||||
.claude
|
||||
@@ -695,6 +695,35 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
|
||||
overview := buildAdminOverview([]services.AdminBox{{
|
||||
ID: "box1",
|
||||
CreatedAt: today,
|
||||
TotalSize: 1024,
|
||||
}}, services.AdminStats{TotalBoxes: 1, TotalFiles: 1, TotalSize: 1024})
|
||||
|
||||
for i, bar := range overview.UploadDays {
|
||||
want := 0
|
||||
if i == len(overview.UploadDays)-1 {
|
||||
want = 100
|
||||
}
|
||||
if bar.Height != want {
|
||||
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want)
|
||||
}
|
||||
}
|
||||
for i, bar := range overview.StorageDays {
|
||||
want := 0
|
||||
if i == len(overview.StorageDays)-1 {
|
||||
want = 100
|
||||
}
|
||||
if bar.Height != want {
|
||||
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageProviderPagesOnlyRenderRelevantFields(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
@@ -36,6 +37,7 @@ type adminPageData struct {
|
||||
StorageTypes []adminStorageProviderView
|
||||
Logs adminLogsView
|
||||
Bans adminBansView
|
||||
Overview adminOverview
|
||||
Section string
|
||||
PageTitle string
|
||||
LastInviteURL string
|
||||
@@ -44,16 +46,32 @@ type adminPageData struct {
|
||||
}
|
||||
|
||||
type adminLogsView struct {
|
||||
Entries []adminLogEntry
|
||||
Dates []string
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
TotalShown int
|
||||
Entries []adminLogEntry
|
||||
Dates []string
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
TotalShown int
|
||||
Total int
|
||||
Page int
|
||||
PerPage int
|
||||
PerPageOptions []int
|
||||
TotalPages int
|
||||
RangeFrom int
|
||||
RangeTo int
|
||||
PageLinks []adminFilesPageLink
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevHref string
|
||||
NextHref string
|
||||
}
|
||||
|
||||
var adminLogsPageSizes = []int{50, 100, 250, 500}
|
||||
|
||||
const adminLogsDefaultPageSize = 100
|
||||
|
||||
type adminLogEntry struct {
|
||||
Date string
|
||||
Time string
|
||||
@@ -134,6 +152,24 @@ type adminStorageProviderView struct {
|
||||
Icon string
|
||||
}
|
||||
|
||||
type adminOverview struct {
|
||||
UploadDays []adminChartBar
|
||||
StorageDays []adminChartBar
|
||||
StatusBars []adminStatBar
|
||||
}
|
||||
|
||||
type adminChartBar struct {
|
||||
Label string
|
||||
Value string
|
||||
Height int // 0-100, percent of the tallest bar
|
||||
}
|
||||
|
||||
type adminStatBar struct {
|
||||
Label string
|
||||
Value string
|
||||
Percent int
|
||||
}
|
||||
|
||||
type adminBoxView struct {
|
||||
ID string
|
||||
Owner string
|
||||
@@ -248,52 +284,140 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
boxes, err := a.adminBoxes(8)
|
||||
allBoxes, err := a.uploadService.AdminBoxes(0)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
|
||||
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
overview := buildAdminOverview(allBoxes, stats)
|
||||
recent := a.recentBoxViews(allBoxes, 8)
|
||||
|
||||
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
|
||||
Title: "Admin overview",
|
||||
Description: "Warpbox admin overview.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Boxes: boxes,
|
||||
Boxes: recent,
|
||||
Overview: overview,
|
||||
Section: "overview",
|
||||
PageTitle: "Admin overview",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
// recentBoxViews renders the newest boxes (already sorted newest-first by the
|
||||
// service) into display rows, resolving owner labels.
|
||||
func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxView {
|
||||
if limit > 0 && len(boxes) > limit {
|
||||
boxes = boxes[:limit]
|
||||
}
|
||||
cache := map[string]string{}
|
||||
rows := make([]adminBoxView, 0, len(boxes))
|
||||
for _, box := range boxes {
|
||||
rows = append(rows, adminBoxView{
|
||||
ID: box.ID,
|
||||
Owner: a.boxOwnerLabel(box.OwnerID, cache),
|
||||
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04"),
|
||||
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04"),
|
||||
FileCount: box.FileCount,
|
||||
TotalSizeLabel: box.TotalSizeLabel,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
Protected: box.Protected,
|
||||
Expired: box.Expired,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildAdminOverview computes the last-14-day upload/storage series plus a few
|
||||
// status distributions for the overview dashboard.
|
||||
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
|
||||
const days = 14
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
counts := make([]int, days)
|
||||
bytes := make([]int64, days)
|
||||
labels := make([]string, days)
|
||||
for i := 0; i < days; i++ {
|
||||
day := today.AddDate(0, 0, -(days - 1 - i))
|
||||
labels[i] = day.Format("Jan 2")
|
||||
}
|
||||
|
||||
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
|
||||
for _, box := range boxes {
|
||||
created := box.CreatedAt.UTC()
|
||||
day := time.Date(created.Year(), created.Month(), created.Day(), 0, 0, 0, 0, time.UTC)
|
||||
offset := int(today.Sub(day).Hours() / 24)
|
||||
idx := days - 1 - offset
|
||||
if idx < 0 || idx >= days {
|
||||
continue
|
||||
}
|
||||
counts[idx]++
|
||||
bytes[idx] += box.TotalSize
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
})
|
||||
maxCount := 0
|
||||
var maxBytes int64
|
||||
for i := 0; i < days; i++ {
|
||||
if counts[i] > maxCount {
|
||||
maxCount = counts[i]
|
||||
}
|
||||
if bytes[i] > maxBytes {
|
||||
maxBytes = bytes[i]
|
||||
}
|
||||
}
|
||||
|
||||
uploadDays := make([]adminChartBar, days)
|
||||
storageDays := make([]adminChartBar, days)
|
||||
for i := 0; i < days; i++ {
|
||||
uploadDays[i] = adminChartBar{
|
||||
Label: labels[i],
|
||||
Value: strconv.Itoa(counts[i]),
|
||||
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
|
||||
}
|
||||
storageDays[i] = adminChartBar{
|
||||
Label: labels[i],
|
||||
Value: helpers.FormatBytes(bytes[i]),
|
||||
Height: scaleHeight(bytes[i], maxBytes),
|
||||
}
|
||||
}
|
||||
|
||||
activeBoxes := stats.TotalBoxes - stats.ExpiredBoxes
|
||||
if activeBoxes < 0 {
|
||||
activeBoxes = 0
|
||||
}
|
||||
statusBars := []adminStatBar{
|
||||
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)},
|
||||
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)},
|
||||
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)},
|
||||
}
|
||||
|
||||
return adminOverview{
|
||||
UploadDays: uploadDays,
|
||||
StorageDays: storageDays,
|
||||
StatusBars: statusBars,
|
||||
}
|
||||
}
|
||||
|
||||
func scaleHeight(value, max int64) int {
|
||||
if max <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
height := int(value * 100 / max)
|
||||
if height < 4 {
|
||||
height = 4
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
func percentOf(value, total int) int {
|
||||
if total <= 0 || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value * 100 / total
|
||||
}
|
||||
|
||||
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1203,38 +1327,6 @@ func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status in
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
|
||||
boxes, err := a.uploadService.AdminBoxes(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]adminBoxView, 0, len(boxes))
|
||||
for _, box := range boxes {
|
||||
owner := "Anonymous"
|
||||
if box.OwnerID != "" {
|
||||
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
|
||||
owner = user.Email
|
||||
} else {
|
||||
owner = "User"
|
||||
}
|
||||
}
|
||||
rows = append(rows, adminBoxView{
|
||||
ID: box.ID,
|
||||
Owner: owner,
|
||||
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
|
||||
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2 15:04"),
|
||||
FileCount: box.FileCount,
|
||||
TotalSizeLabel: box.TotalSizeLabel,
|
||||
DownloadCount: box.DownloadCount,
|
||||
MaxDownloads: box.MaxDownloads,
|
||||
Protected: box.Protected,
|
||||
Expired: box.Expired,
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if a.isAdmin(r) {
|
||||
return true
|
||||
@@ -1494,21 +1586,111 @@ func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) {
|
||||
}
|
||||
return left > right
|
||||
})
|
||||
if len(entries) > 500 {
|
||||
entries = entries[:500]
|
||||
|
||||
perPage := normalizePageSize(r.URL.Query().Get("per"), adminLogsDefaultPageSize, adminLogsPageSizes)
|
||||
total := len(entries)
|
||||
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
|
||||
}
|
||||
rangeFrom := 0
|
||||
if total > 0 {
|
||||
rangeFrom = start + 1
|
||||
}
|
||||
|
||||
state := adminLogsQuery{
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
Per: perPage,
|
||||
}
|
||||
links := make([]adminFilesPageLink, 0, 5)
|
||||
for p := page - 2; p <= page+2; p++ {
|
||||
if p < 1 || p > totalPages {
|
||||
continue
|
||||
}
|
||||
links = append(links, adminFilesPageLink{Page: p, Href: adminLogsHref(state, p), Active: p == page})
|
||||
}
|
||||
|
||||
return adminLogsView{
|
||||
Entries: entries,
|
||||
Dates: dates,
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
TotalShown: len(entries),
|
||||
Entries: entries[start:end],
|
||||
Dates: dates,
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
TotalShown: end - start,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
PerPageOptions: adminLogsPageSizes,
|
||||
TotalPages: totalPages,
|
||||
RangeFrom: rangeFrom,
|
||||
RangeTo: end,
|
||||
PageLinks: links,
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevHref: adminLogsHref(state, page-1),
|
||||
NextHref: adminLogsHref(state, page+1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type adminLogsQuery struct {
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
Per int
|
||||
}
|
||||
|
||||
func adminLogsHref(state adminLogsQuery, page int) string {
|
||||
values := url.Values{}
|
||||
if state.Date != "" {
|
||||
values.Set("date", state.Date)
|
||||
}
|
||||
if state.Severity != "" {
|
||||
values.Set("severity", state.Severity)
|
||||
}
|
||||
if state.Source != "" {
|
||||
values.Set("source", state.Source)
|
||||
}
|
||||
if state.Query != "" {
|
||||
values.Set("q", state.Query)
|
||||
}
|
||||
if state.Sort != "" && state.Sort != "desc" {
|
||||
values.Set("sort", state.Sort)
|
||||
}
|
||||
if state.Per > 0 && state.Per != adminLogsDefaultPageSize {
|
||||
values.Set("per", strconv.Itoa(state.Per))
|
||||
}
|
||||
if page > 1 {
|
||||
values.Set("page", strconv.Itoa(page))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return "/admin/logs"
|
||||
}
|
||||
return "/admin/logs?" + values.Encode()
|
||||
}
|
||||
|
||||
func availableLogDates(logDir string) ([]string, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
|
||||
492
backend/libs/handlers/admin_files.go
Normal file
492
backend/libs/handlers/admin_files.go
Normal file
@@ -0,0 +1,492 @@
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-edit-metrics {
|
||||
.user-edit-metrics,
|
||||
.metric-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -106,6 +107,185 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.pagination-bar .pagination {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.per-page-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.per-page-control select {
|
||||
width: auto;
|
||||
min-width: 4.5rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.button.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* Overview charts */
|
||||
.admin-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chart-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.chart-card .muted-copy {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.4rem;
|
||||
height: 180px;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-chart-col {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar-chart-track {
|
||||
width: 100%;
|
||||
max-width: 2.2rem;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bar-chart-bar {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: linear-gradient(180deg, var(--primary, #8b5cf6), color-mix(in srgb, var(--primary, #8b5cf6) 55%, transparent));
|
||||
}
|
||||
|
||||
.bar-chart-value {
|
||||
color: var(--foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.bar-chart-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.66rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-bars {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-bar span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.stat-bar span strong {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.stat-bar-track {
|
||||
margin-top: 0.35rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--primary, #8b5cf6);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.metric-grid-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
43
backend/static/js/35-pagination.js
Normal file
43
backend/static/js/35-pagination.js
Normal file
@@ -0,0 +1,43 @@
|
||||
// Per-page selector: remembers the chosen page size in localStorage and keeps
|
||||
// the URL's `per` query param in sync. CSP-safe (external file, no inline JS).
|
||||
(function () {
|
||||
const select = document.querySelector("[data-per-page]");
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = "warpbox-perpage-" + select.dataset.perPage;
|
||||
const url = new URL(window.location.href);
|
||||
const current = url.searchParams.get("per");
|
||||
let stored = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(key);
|
||||
} catch (err) {
|
||||
stored = null;
|
||||
}
|
||||
|
||||
// No explicit choice in the URL but a remembered preference exists: apply it.
|
||||
if (!current && stored && stored !== select.value) {
|
||||
const valid = Array.prototype.some.call(select.options, function (opt) {
|
||||
return opt.value === stored;
|
||||
});
|
||||
if (valid) {
|
||||
url.searchParams.set("per", stored);
|
||||
url.searchParams.delete("page");
|
||||
window.location.replace(url.toString());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
select.addEventListener("change", function () {
|
||||
try {
|
||||
window.localStorage.setItem(key, select.value);
|
||||
} catch (err) {
|
||||
/* ignore storage failures (private mode, etc.) */
|
||||
}
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.set("per", select.value);
|
||||
next.searchParams.delete("page");
|
||||
window.location.assign(next.toString());
|
||||
});
|
||||
})();
|
||||
@@ -33,6 +33,7 @@
|
||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||
</head>
|
||||
<body class="dark">
|
||||
|
||||
@@ -58,6 +58,55 @@
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="admin-charts">
|
||||
<div class="card chart-card">
|
||||
<div class="card-content">
|
||||
<h2>Uploads per day</h2>
|
||||
<p class="muted-copy">New boxes created over the last 14 days.</p>
|
||||
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
|
||||
{{range .Data.Overview.UploadDays}}
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||
<span class="bar-chart-value">{{.Value}}</span>
|
||||
<span class="bar-chart-track"><span class="bar-chart-bar" style="height: {{.Height}}%"></span></span>
|
||||
<span class="bar-chart-label">{{.Label}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-card">
|
||||
<div class="card-content">
|
||||
<h2>Box status</h2>
|
||||
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
|
||||
<div class="stat-bars">
|
||||
{{range .Data.Overview.StatusBars}}
|
||||
<div class="stat-bar">
|
||||
<span>{{.Label}} <strong>{{.Value}}</strong></span>
|
||||
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card chart-card">
|
||||
<div class="card-content">
|
||||
<h2>Storage added per day</h2>
|
||||
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
|
||||
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
|
||||
{{range .Data.Overview.StorageDays}}
|
||||
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||
<span class="bar-chart-value">{{.Value}}</span>
|
||||
<span class="bar-chart-track"><span class="bar-chart-bar" style="height: {{.Height}}%"></span></span>
|
||||
<span class="bar-chart-label">{{.Label}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}}
|
||||
{{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}}
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-grid metric-grid-4">
|
||||
<article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article>
|
||||
<article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
|
||||
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>
|
||||
|
||||
131
backend/templates/pages/admin_box_edit.html
Normal file
131
backend/templates/pages/admin_box_edit.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{{define "admin_box_edit.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-box-edit-title">
|
||||
<aside class="app-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<form class="sidebar-logout" action="/admin/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<p class="kicker">Operator console · <a href="/admin/files">Files</a></p>
|
||||
<h1 id="admin-box-edit-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">Box <code>{{.Data.Box.ID}}</code> · {{.Data.Box.Owner}}</p>
|
||||
</div>
|
||||
<a class="button button-outline" href="/admin/boxes/{{.Data.Box.ID}}/view">Open box</a>
|
||||
</div>
|
||||
|
||||
{{if .Data.Notice}}<p class="form-success">{{.Data.Notice}}</p>{{end}}
|
||||
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Box settings</h2>
|
||||
<p>Change expiration, download limit, and protection.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="manage-details">
|
||||
<div><dt>Created</dt><dd>{{.Data.Box.CreatedAt}}</dd></div>
|
||||
<div><dt>Files</dt><dd>{{.Data.Box.FileCount}}</dd></div>
|
||||
<div><dt>Total size</dt><dd>{{.Data.Box.TotalSize}}</dd></div>
|
||||
<div><dt>Downloads</dt><dd>{{.Data.Box.DownloadCount}}{{if .Data.Box.MaxDownloads}} / {{.Data.Box.MaxDownloads}}{{end}}</dd></div>
|
||||
<div><dt>Expires</dt><dd>{{.Data.Box.ExpiresLabel}}</dd></div>
|
||||
<div><dt>Storage backend</dt><dd>{{.Data.Box.BackendID}}</dd></div>
|
||||
<div><dt>Protected</dt><dd>{{if .Data.Box.Protected}}Yes{{else}}No{{end}}</dd></div>
|
||||
</dl>
|
||||
|
||||
<form class="settings-form settings-form-narrow" action="/admin/boxes/{{.Data.Box.ID}}/edit" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<label>
|
||||
<span>Expires at (UTC)</span>
|
||||
<input type="datetime-local" name="expires_at" value="{{.Data.Box.ExpiresInput}}">
|
||||
</label>
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="never_expires" {{if .Data.Box.NeverExpires}}checked{{end}}>
|
||||
<span>Never expires (overrides the date above)</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>Max downloads (0 = unlimited)</span>
|
||||
<input type="number" min="0" name="max_downloads" value="{{.Data.Box.MaxDownloads}}">
|
||||
</label>
|
||||
{{if .Data.Box.Protected}}
|
||||
<label class="checkbox-field">
|
||||
<input type="checkbox" name="remove_password">
|
||||
<span>Remove password protection</span>
|
||||
</label>
|
||||
{{end}}
|
||||
<button class="button button-primary" type="submit">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Files</h2>
|
||||
<p>Remove individual files from this box. Removing the last file deletes the box.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-list">
|
||||
{{range .Data.Files}}
|
||||
<article class="download-item">
|
||||
{{if .HasPreview}}<a class="thumb-link" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer"><img src="{{.ThumbnailURL}}" alt="" loading="lazy"></a>{{end}}
|
||||
<a class="file-main" href="{{.DownloadURL}}?inline=1" target="_blank" rel="noopener noreferrer">
|
||||
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
|
||||
<small>{{.Size}} · {{.ContentType}}</small>
|
||||
</a>
|
||||
<div class="file-actions">
|
||||
<a class="button button-outline button-sm" href="{{.DownloadURL}}" download="{{.Name}}">Download</a>
|
||||
<form action="/admin/boxes/{{$.Data.Box.ID}}/files/{{.ID}}/delete" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{{else}}
|
||||
<p class="muted-copy">This box has no files.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Danger zone</h2>
|
||||
<p>Permanently delete this box and all of its files.</p>
|
||||
</div>
|
||||
<form action="/admin/boxes/{{.Data.Box.ID}}/delete" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button button-danger" type="submit">Delete box</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
114
backend/templates/pages/admin_files.html
Normal file
114
backend/templates/pages/admin_files.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{{define "admin_files.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="app-shell admin-shell" aria-labelledby="admin-files-title">
|
||||
<aside class="app-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<a class="sidebar-link" href="/admin">{{template "icon-dashboard" .}}<span>Overview</span></a>
|
||||
<a class="sidebar-link is-active" href="/admin/files">{{template "icon-folder" .}}<span>Files</span></a>
|
||||
<a class="sidebar-link" href="/admin/users">{{template "icon-user-circle" .}}<span>Users</span></a>
|
||||
<a class="sidebar-link" href="/admin/settings">{{template "icon-settings" .}}<span>Settings</span></a>
|
||||
<a class="sidebar-link" href="/admin/storage">{{template "icon-database" .}}<span>Storage</span></a>
|
||||
<a class="sidebar-link" href="/admin/logs">{{template "icon-database" .}}<span>Logs</span></a>
|
||||
<a class="sidebar-link" href="/admin/bans">{{template "icon-settings" .}}<span>Bans</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<nav class="sidebar-nav">
|
||||
<a class="sidebar-link" href="/app">{{template "icon-home-simple" .}}<span>My Files</span></a>
|
||||
</nav>
|
||||
<hr class="sidebar-sep">
|
||||
<form class="sidebar-logout" action="/admin/logout" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<button class="button button-outline" type="submit">{{template "icon-log-out" .}}<span>Sign out</span></button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<div class="app-main">
|
||||
<div class="admin-header">
|
||||
<div>
|
||||
<p class="kicker">Operator console</p>
|
||||
<h1 id="admin-files-title">{{.Data.PageTitle}}</h1>
|
||||
<p class="muted-copy">{{.Data.Total}} box{{if ne .Data.Total 1}}es{{end}} total.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card admin-table-card">
|
||||
<div class="card-content">
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>All uploads</h2>
|
||||
<p>Search, sort, and manage every box.</p>
|
||||
</div>
|
||||
<form class="inline-controls" method="get" action="/admin/files">
|
||||
<input type="hidden" name="sort" value="{{.Data.Sort}}">
|
||||
<input type="hidden" name="dir" value="{{.Data.Dir}}">
|
||||
<input type="hidden" name="per" value="{{.Data.PerPage}}">
|
||||
<label>
|
||||
<span class="sr-only">Search</span>
|
||||
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
|
||||
</label>
|
||||
<button class="button button-primary button-sm" type="submit">Search</button>
|
||||
{{if .Data.Query}}<a class="button button-outline button-sm" href="/admin/files">Clear</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{{range .Data.Columns}}
|
||||
<th><a class="sort-link {{if .Sorted}}is-sorted{{end}}" href="{{.Href}}">{{.Label}}{{if .Sorted}}<span class="sort-arrow" aria-hidden="true">{{if .Ascending}}▲{{else}}▼{{end}}</span>{{end}}</a></th>
|
||||
{{end}}
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Data.Boxes}}
|
||||
<tr>
|
||||
<td><a href="/admin/boxes/{{.ID}}/edit"><code>{{.ID}}</code></a></td>
|
||||
<td>{{.Owner}}</td>
|
||||
<td>{{.FileCount}}</td>
|
||||
<td>{{.TotalSizeLabel}}</td>
|
||||
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{.ExpiresAt}}</td>
|
||||
<td>
|
||||
{{if .Expired}}<span class="badge">expired</span>{{else}}<span class="badge">active</span>{{end}}
|
||||
{{if .Protected}}<span class="badge">protected</span>{{end}}
|
||||
</td>
|
||||
<td class="table-actions">
|
||||
<a class="button button-primary button-sm" href="/admin/boxes/{{.ID}}/edit">Edit</a>
|
||||
<a class="button button-outline button-sm" href="/admin/boxes/{{.ID}}/view">View</a>
|
||||
<form action="/admin/boxes/{{.ID}}/delete" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<button class="button button-danger button-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="9">No boxes match.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-bar">
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
|
||||
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
||||
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
|
||||
</nav>
|
||||
<label class="per-page-control">
|
||||
<span>Per page</span>
|
||||
<select data-per-page="files" aria-label="Items per page">
|
||||
{{range .Data.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.PerPage}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="pagination-summary">Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -54,6 +54,7 @@
|
||||
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="per" value="{{.Data.Logs.PerPage}}">
|
||||
<button class="button button-primary" type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
@@ -62,7 +63,7 @@
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Log entries</h2>
|
||||
<p>Showing up to 500 entries. {{.Data.Logs.TotalShown}} currently visible.</p>
|
||||
<p>{{.Data.Logs.Total}} entries match these filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
@@ -98,6 +99,21 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-bar">
|
||||
<nav class="pagination" aria-label="Pagination">
|
||||
{{if .Data.Logs.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.Logs.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
|
||||
{{range .Data.Logs.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
||||
{{if .Data.Logs.HasNext}}<a class="button button-outline button-sm" href="{{.Data.Logs.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
|
||||
</nav>
|
||||
<label class="per-page-control">
|
||||
<span>Per page</span>
|
||||
<select data-per-page="logs" aria-label="Items per page">
|
||||
{{range .Data.Logs.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.Logs.PerPage}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="pagination-summary">Showing {{.Data.Logs.RangeFrom}}–{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user