feat(setting): Implemented the settings administrative menu

This commit is contained in:
2026-05-01 01:51:06 +03:00
parent 36d49a970e
commit d0aa86205f
20 changed files with 3759 additions and 42 deletions

316
lib/server/admin_boxes.go Normal file
View File

@@ -0,0 +1,316 @@
package server
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
)
type adminBoxesActionRequest struct {
Action string `json:"action"`
BoxIDs []string `json:"box_ids"`
DeltaSeconds int64 `json:"delta_seconds,omitempty"`
}
type adminBoxFileView struct {
Name string `json:"name"`
SizeLabel string `json:"size_label"`
MimeType string `json:"mime_type"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
DownloadPath string `json:"download_path"`
ThumbnailURL string `json:"thumbnail_url"`
IsComplete bool `json:"is_complete"`
}
type adminBoxView struct {
ID string `json:"id"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
FileCount int `json:"file_count"`
CompleteFiles int `json:"complete_files"`
PendingFiles int `json:"pending_files"`
FailedFiles int `json:"failed_files"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"`
CreatedAtISO string `json:"created_at_iso"`
ExpiresAtLabel string `json:"expires_at_label"`
ExpiresAtISO string `json:"expires_at_iso"`
RetentionLabel string `json:"retention_label"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
ZipDisabled bool `json:"zip_disabled"`
ZipAvailable bool `json:"zip_available"`
Consumed bool `json:"consumed"`
HasManifest bool `json:"has_manifest"`
OpenURL string `json:"open_url"`
ZipURL string `json:"zip_url"`
Flags []string `json:"flags"`
Files []adminBoxFileView `json:"files"`
SearchText string `json:"search_text"`
}
func (app *App) handleAdminBoxes(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
boxes, err := app.listAdminBoxes()
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load boxes")
return
}
ctx.HTML(http.StatusOK, "admin/boxes.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "boxes",
"Boxes": boxes,
"ZipDownloadsOn": app.config.ZipDownloadsEnabled,
})
}
func (app *App) handleAdminBoxesAction(ctx *gin.Context) {
var request adminBoxesActionRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid action payload"})
return
}
if len(request.BoxIDs) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Select one or more boxes first"})
return
}
switch request.Action {
case "delete", "expire", "bump":
default:
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Unknown action"})
return
}
if request.Action == "bump" && request.DeltaSeconds <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing bump duration"})
return
}
processed := 0
warnings := make([]string, 0)
for _, boxID := range request.BoxIDs {
if !boxstore.ValidBoxID(boxID) {
warnings = append(warnings, fmt.Sprintf("%s: invalid box id", boxID))
continue
}
var err error
switch request.Action {
case "delete":
err = boxstore.DeleteBox(boxID)
case "expire":
_, err = boxstore.ExpireBox(boxID)
case "bump":
_, err = boxstore.BumpBoxExpiry(boxID, time.Duration(request.DeltaSeconds)*time.Second)
}
if err != nil {
warnings = append(warnings, fmt.Sprintf("%s: %v", boxID, err))
continue
}
processed++
}
boxes, err := app.listAdminBoxes()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Action finished, but boxes could not be reloaded"})
return
}
status := http.StatusOK
if processed == 0 && len(warnings) > 0 {
status = http.StatusBadRequest
}
ctx.JSON(status, gin.H{
"ok": len(warnings) == 0,
"message": adminBoxesActionMessage(request.Action, processed, request.DeltaSeconds),
"warnings": warnings,
"boxes": boxes,
})
}
func (app *App) listAdminBoxes() ([]adminBoxView, error) {
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
return nil, err
}
boxes := make([]adminBoxView, 0, len(summaries))
for _, summary := range summaries {
boxView, err := app.buildAdminBoxView(summary.ID)
if err != nil {
continue
}
boxes = append(boxes, boxView)
}
sort.Slice(boxes, func(i, j int) bool {
return boxes[i].CreatedAtISO > boxes[j].CreatedAtISO
})
return boxes, nil
}
func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
summary, err := boxstore.BoxSummary(boxID)
if err != nil {
return adminBoxView{}, err
}
files, err := boxstore.ListFiles(boxID)
if err != nil {
return adminBoxView{}, err
}
manifest, manifestErr := boxstore.ReadManifest(boxID)
hasManifest := manifestErr == nil
boxView := adminBoxView{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
CreatedAtISO: formatBrowserTime(summary.CreatedAt),
ExpiresAtLabel: "Not set",
ExpiresAtISO: formatBrowserTime(summary.ExpiresAt),
RetentionLabel: "Legacy / unmanaged",
PasswordProtected: summary.PasswordProtected,
OneTimeDownload: summary.OneTimeDownload,
HasManifest: hasManifest,
OpenURL: "/box/" + summary.ID,
Files: make([]adminBoxFileView, 0, len(files)),
}
if !summary.ExpiresAt.IsZero() {
boxView.ExpiresAtLabel = adminTimeLabel(summary.ExpiresAt)
}
searchParts := []string{summary.ID, summary.TotalSizeLabel}
for _, file := range files {
if file.IsComplete {
boxView.CompleteFiles++
}
if file.Status == "failed" {
boxView.FailedFiles++
}
if !file.IsComplete && file.Status != "failed" {
boxView.PendingFiles++
}
boxView.Files = append(boxView.Files, adminBoxFileView{
Name: file.Name,
SizeLabel: file.SizeLabel,
MimeType: file.MimeType,
Status: file.Status,
StatusLabel: file.StatusLabel,
DownloadPath: file.DownloadPath,
ThumbnailURL: file.ThumbnailURL,
IsComplete: file.IsComplete,
})
searchParts = append(searchParts, file.Name, file.MimeType, file.StatusLabel)
}
if hasManifest {
boxView.RetentionLabel = manifest.RetentionLabel
boxView.ZipDisabled = manifest.DisableZip
boxView.Consumed = manifest.Consumed
} else {
boxView.ZipDisabled = false
}
boxView.ZipAvailable = app.config.ZipDownloadsEnabled && !boxView.ZipDisabled && !boxView.Consumed && boxView.FileCount > 0 && boxView.PendingFiles == 0
if boxView.ZipAvailable {
boxView.ZipURL = "/box/" + summary.ID + "/download"
}
boxView.Status, boxView.StatusLabel = deriveAdminBoxStatus(hasManifest, summary.Expired, boxView.PendingFiles, boxView.FailedFiles, boxView.Consumed)
boxView.Flags = deriveAdminBoxFlags(boxView)
searchParts = append(searchParts, boxView.StatusLabel, boxView.RetentionLabel)
boxView.SearchText = strings.ToLower(strings.Join(searchParts, " "))
return boxView, nil
}
func deriveAdminBoxStatus(hasManifest bool, expired bool, pendingFiles int, failedFiles int, consumed bool) (string, string) {
switch {
case !hasManifest:
return "legacy", "Legacy"
case consumed:
return "consumed", "Consumed"
case expired:
return "expired", "Expired"
case pendingFiles > 0:
return "uploading", "Uploading"
case failedFiles > 0:
return "attention", "Needs review"
default:
return "ready", "Ready"
}
}
func deriveAdminBoxFlags(box adminBoxView) []string {
flags := make([]string, 0, 5)
if box.PasswordProtected {
flags = append(flags, "protected")
}
if box.OneTimeDownload {
flags = append(flags, "one-time")
}
if box.ZipDisabled {
flags = append(flags, "zip off")
}
if !box.HasManifest {
flags = append(flags, "legacy")
}
if box.Consumed {
flags = append(flags, "consumed")
}
return flags
}
func adminTimeLabel(value time.Time) string {
if value.IsZero() {
return "Not set"
}
return value.UTC().Format("2006-01-02 15:04 UTC")
}
func adminBoxesActionMessage(action string, processed int, deltaSeconds int64) string {
switch action {
case "delete":
return fmt.Sprintf("Deleted %d box(es)", processed)
case "expire":
return fmt.Sprintf("Expired %d box(es)", processed)
case "bump":
return fmt.Sprintf("Extended %d box(es) by %s", processed, adminBoxesDeltaLabel(deltaSeconds))
default:
return "Action complete"
}
}
func adminBoxesDeltaLabel(deltaSeconds int64) string {
switch deltaSeconds {
case 24 * 60 * 60:
return "24h"
case 7 * 24 * 60 * 60:
return "7d"
default:
return (time.Duration(deltaSeconds) * time.Second).String()
}
}