1 Commits

Author SHA1 Message Date
cc91ce120d feat(admin): allow editing boxes and deleting individual files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m44s
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`.
2026-06-01 03:39:45 +03:00
9 changed files with 805 additions and 32 deletions

2
.gitignore vendored
View File

@@ -15,3 +15,5 @@ backend/static/uploads/*
.prod.env .prod.env
scripts/env/dev.env scripts/env/dev.env
docker-compose.yml docker-compose.yml
.claude

View File

@@ -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) { func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) {
return return

View File

@@ -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)
}

View File

@@ -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}/policy", a.AdminUpdateUserPolicy)
mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage) mux.HandleFunc("POST /admin/users/{userID}/storage", a.AdminUpdateUserStorage)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) 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("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)
mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted) mux.HandleFunc("GET /d/{boxID}/deleted", a.ManageDeleted)

View File

@@ -613,6 +613,80 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
return nil 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) { func (s *UploadService) FindFile(box Box, fileID string) (File, error) {
for _, file := range box.Files { for _, file := range box.Files {
if file.ID == fileID { if file.ID == fileID {

View File

@@ -152,16 +152,16 @@
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs /* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
are styled as their own Win98 controls below, so they're excluded here. */ 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; color: #0000ee;
text-decoration: underline; 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; 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; color: #ee0000;
} }

View File

@@ -106,6 +106,38 @@
font-weight: 650; 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 { .table-actions {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;

View 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}}

View File

@@ -0,0 +1,107 @@
{{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}}">
<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>
{{if gt .Data.TotalPages 1}}
<nav class="pagination" aria-label="Pagination">
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{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>{{end}}
</nav>
{{end}}
<p class="pagination-summary">Showing {{.Data.RangeFrom}}{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
</div>
</div>
</div>
</section>
{{end}}