feat(setting): Implemented the settings administrative menu
This commit is contained in:
316
lib/server/admin_boxes.go
Normal file
316
lib/server/admin_boxes.go
Normal 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()
|
||||
}
|
||||
}
|
||||
461
lib/server/admin_settings.go
Normal file
461
lib/server/admin_settings.go
Normal file
@@ -0,0 +1,461 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
type adminSettingsCategoryView struct {
|
||||
Key string
|
||||
Label string
|
||||
Icon string
|
||||
Count int
|
||||
Rows []adminSettingRowView
|
||||
}
|
||||
|
||||
type adminSettingRowView struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
EnvName string `json:"env_name"`
|
||||
Category string `json:"category"`
|
||||
CategoryLabel string `json:"category_label"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
DefaultValue string `json:"default_value"`
|
||||
Source string `json:"source"`
|
||||
SourceBadge string `json:"source_badge"`
|
||||
Editable bool `json:"editable"`
|
||||
Locked bool `json:"locked"`
|
||||
HardLimit bool `json:"hard_limit"`
|
||||
Minimum int64 `json:"minimum"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type adminSettingsSaveRequest struct {
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
type adminSettingsImportRequest struct {
|
||||
Settings map[string]string `json:"settings"`
|
||||
EditableSettings map[string]string `json:"editable_settings"`
|
||||
Values map[string]string `json:"values"`
|
||||
Changes map[string]string `json:"changes"`
|
||||
}
|
||||
|
||||
type adminSettingsResetRequest struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
|
||||
type adminSettingsExportResponse struct {
|
||||
Format string `json:"format"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
EditableSettings map[string]string `json:"editable_settings"`
|
||||
Rows []adminSettingRowView `json:"rows"`
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettings(ctx *gin.Context) {
|
||||
rows, categories := app.buildAdminSettingsRows()
|
||||
ctx.HTML(http.StatusOK, "admin/settings.html", gin.H{
|
||||
"AdminUsername": app.config.AdminUsername,
|
||||
"AdminEmail": app.config.AdminEmail,
|
||||
"ActivePage": "settings",
|
||||
"Rows": rows,
|
||||
"Categories": categories,
|
||||
"RowsJSON": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsExport(ctx *gin.Context) {
|
||||
rows, _ := app.buildAdminSettingsRows()
|
||||
ctx.JSON(http.StatusOK, app.buildSettingsExportPayload(rows))
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsSave(ctx *gin.Context) {
|
||||
var request adminSettingsSaveRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid save payload"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, warnings, err := app.applySettingsOverrideSet(request.Values)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"message": fmt.Sprintf("Saved %d editable setting(s)", len(request.Values)),
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsImport(ctx *gin.Context) {
|
||||
var request adminSettingsImportRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid import payload"})
|
||||
return
|
||||
}
|
||||
|
||||
values := request.Values
|
||||
if len(values) == 0 {
|
||||
values = request.Settings
|
||||
}
|
||||
if len(values) == 0 {
|
||||
values = request.EditableSettings
|
||||
}
|
||||
if len(values) == 0 {
|
||||
values = request.Changes
|
||||
}
|
||||
if len(values) == 0 {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No importable settings found"})
|
||||
return
|
||||
}
|
||||
|
||||
editable := map[string]bool{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
editable[def.Key] = true
|
||||
}
|
||||
filtered := make(map[string]string, len(values))
|
||||
warnings := make([]string, 0)
|
||||
for key, value := range values {
|
||||
if editable[key] {
|
||||
filtered[key] = value
|
||||
continue
|
||||
}
|
||||
if _, found := config.Definition(key); found {
|
||||
warnings = append(warnings, fmt.Sprintf("%s skipped: locked", key))
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||
}
|
||||
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load current settings overrides"})
|
||||
return
|
||||
}
|
||||
for key, value := range currentOverrides {
|
||||
if _, exists := filtered[key]; !exists {
|
||||
filtered[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
rows, applyWarnings, err := app.applySettingsOverrideSet(filtered)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
warnings = append(warnings, applyWarnings...)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"message": fmt.Sprintf("Imported %d setting value(s)", len(values)),
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) handleAdminSettingsReset(ctx *gin.Context) {
|
||||
var request adminSettingsResetRequest
|
||||
_ = ctx.ShouldBindJSON(&request)
|
||||
|
||||
defs := config.EditableDefinitions()
|
||||
overrideSet := make(map[string]string, len(defs))
|
||||
targetKeys := map[string]bool{}
|
||||
for _, key := range request.Keys {
|
||||
targetKeys[key] = true
|
||||
}
|
||||
|
||||
if len(targetKeys) == 0 {
|
||||
for _, def := range defs {
|
||||
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
||||
}
|
||||
} else {
|
||||
currentOverrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Could not load settings overrides"})
|
||||
return
|
||||
}
|
||||
for key, value := range currentOverrides {
|
||||
overrideSet[key] = value
|
||||
}
|
||||
for _, def := range defs {
|
||||
if targetKeys[def.Key] {
|
||||
overrideSet[def.Key] = app.config.DefaultValue(def.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows, warnings, err := app.applySettingsOverrideSet(overrideSet)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"message": "Editable settings reset to application defaults",
|
||||
"warnings": warnings,
|
||||
"rows": rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) applySettingsOverrideSet(values map[string]string) ([]adminSettingRowView, []string, error) {
|
||||
if !app.config.AllowAdminSettingsOverride {
|
||||
return nil, nil, fmt.Errorf("runtime admin setting overrides are disabled by environment")
|
||||
}
|
||||
if values == nil {
|
||||
values = map[string]string{}
|
||||
}
|
||||
|
||||
overrideSet := make(map[string]string, len(values))
|
||||
warnings := make([]string, 0)
|
||||
editable := map[string]config.SettingDefinition{}
|
||||
for _, def := range config.EditableDefinitions() {
|
||||
editable[def.Key] = def
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(values[key])
|
||||
def, ok := editable[key]
|
||||
if !ok {
|
||||
if _, found := config.Definition(key); found {
|
||||
return nil, nil, fmt.Errorf("setting %q is locked and cannot be changed", key)
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s skipped: unknown key", key))
|
||||
continue
|
||||
}
|
||||
if value == "" && def.Type != config.SettingTypeText {
|
||||
return nil, nil, fmt.Errorf("setting %q cannot be blank", key)
|
||||
}
|
||||
overrideSet[key] = value
|
||||
}
|
||||
|
||||
nextCfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := nextCfg.ApplyOverrides(overrideSet); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := config.WriteAdminSettingsOverrides(app.settingsOverridesPath, overrideSet); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
app.config = nextCfg
|
||||
applyBoxstoreRuntimeConfig(app.config)
|
||||
rows, _ := app.buildAdminSettingsRows()
|
||||
return rows, warnings, nil
|
||||
}
|
||||
|
||||
func (app *App) buildSettingsExportPayload(rows []adminSettingRowView) adminSettingsExportResponse {
|
||||
settings := make(map[string]string, len(rows))
|
||||
editable := make(map[string]string)
|
||||
for _, row := range rows {
|
||||
settings[row.Key] = row.Value
|
||||
if row.Editable && !row.Locked {
|
||||
editable[row.Key] = row.Value
|
||||
}
|
||||
}
|
||||
return adminSettingsExportResponse{
|
||||
Format: "warpbox.settings.export.v1",
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Settings: settings,
|
||||
EditableSettings: editable,
|
||||
Rows: rows,
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) buildAdminSettingsRows() ([]adminSettingRowView, []adminSettingsCategoryView) {
|
||||
cfgRows := app.config.SettingRows()
|
||||
rows := make([]adminSettingRowView, 0, len(cfgRows)+5)
|
||||
for _, row := range cfgRows {
|
||||
rows = append(rows, app.makeDefinitionSettingRow(row))
|
||||
}
|
||||
rows = append(rows,
|
||||
app.makeLockedSettingRow("admin_username", "Admin username", "WARPBOX_ADMIN_USERNAME", "accounts", "admin", app.config.AdminUsername, "Environment-controlled admin login name."),
|
||||
app.makeLockedSettingRow("admin_email", "Admin email", "WARPBOX_ADMIN_EMAIL", "accounts", "admin", app.config.AdminEmail, "Administrative contact address used for future account and alert workflows."),
|
||||
app.makeLockedSettingRow("admin_enabled", "Admin enabled mode", "WARPBOX_ADMIN_ENABLED", "accounts", "admin", string(app.config.AdminEnabled), "Controls whether administrative login is disabled, forced on, or auto-detected."),
|
||||
app.makeLockedSettingRow("admin_cookie_secure", "Admin cookie secure", "WARPBOX_ADMIN_COOKIE_SECURE", "accounts", "bool", boolString(app.config.AdminCookieSecure), "Secure admin cookie flag. Locking this avoids accidental auth regressions."),
|
||||
app.makeLockedSettingRow("allow_admin_settings_override", "Admin settings override allowed", "WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE", "accounts", "bool", boolString(app.config.AllowAdminSettingsOverride), "Master switch for runtime admin setting overrides."),
|
||||
)
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Category == rows[j].Category {
|
||||
return rows[i].Label < rows[j].Label
|
||||
}
|
||||
return settingsCategoryRank(rows[i].Category) < settingsCategoryRank(rows[j].Category)
|
||||
})
|
||||
|
||||
categoryMeta := settingsCategoryMeta()
|
||||
categories := make([]adminSettingsCategoryView, 0, len(categoryMeta)+1)
|
||||
allCategory := adminSettingsCategoryView{Key: "all", Label: "All settings", Icon: "▤", Count: len(rows)}
|
||||
categories = append(categories, allCategory)
|
||||
|
||||
grouped := map[string][]adminSettingRowView{}
|
||||
for _, row := range rows {
|
||||
grouped[row.Category] = append(grouped[row.Category], row)
|
||||
}
|
||||
|
||||
for _, meta := range categoryMeta {
|
||||
categories = append(categories, adminSettingsCategoryView{
|
||||
Key: meta.Key,
|
||||
Label: meta.Label,
|
||||
Icon: meta.Icon,
|
||||
Count: len(grouped[meta.Key]),
|
||||
Rows: grouped[meta.Key],
|
||||
})
|
||||
}
|
||||
|
||||
return rows, categories
|
||||
}
|
||||
|
||||
func boolString(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func (app *App) makeDefinitionSettingRow(row config.SettingRow) adminSettingRowView {
|
||||
def := row.Definition
|
||||
locked := !def.Editable || def.HardLimit
|
||||
source := string(row.Source)
|
||||
sourceBadge := source
|
||||
if locked {
|
||||
sourceBadge = "hard env"
|
||||
}
|
||||
return adminSettingRowView{
|
||||
Key: def.Key,
|
||||
Label: def.Label,
|
||||
EnvName: def.EnvName,
|
||||
Category: settingsCategoryForKey(def.Key),
|
||||
CategoryLabel: settingsCategoryLabel(settingsCategoryForKey(def.Key)),
|
||||
Type: string(def.Type),
|
||||
Value: row.Value,
|
||||
DefaultValue: app.config.DefaultValue(def.Key),
|
||||
Source: source,
|
||||
SourceBadge: sourceBadge,
|
||||
Editable: def.Editable && !def.HardLimit,
|
||||
Locked: locked,
|
||||
HardLimit: def.HardLimit,
|
||||
Minimum: def.Minimum,
|
||||
Description: settingsDescription(def.Key),
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) makeLockedSettingRow(key string, label string, envName string, category string, rowType string, value string, description string) adminSettingRowView {
|
||||
return adminSettingRowView{
|
||||
Key: key,
|
||||
Label: label,
|
||||
EnvName: envName,
|
||||
Category: category,
|
||||
CategoryLabel: settingsCategoryLabel(category),
|
||||
Type: rowType,
|
||||
Value: value,
|
||||
DefaultValue: "",
|
||||
Source: "environment",
|
||||
SourceBadge: "hard env",
|
||||
Editable: false,
|
||||
Locked: true,
|
||||
HardLimit: true,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
type settingsCategoryInfo struct {
|
||||
Key string
|
||||
Label string
|
||||
Icon string
|
||||
}
|
||||
|
||||
func settingsCategoryMeta() []settingsCategoryInfo {
|
||||
return []settingsCategoryInfo{
|
||||
{Key: "uploads", Label: "Uploads", Icon: "↥"},
|
||||
{Key: "downloads", Label: "Downloads", Icon: "↧"},
|
||||
{Key: "retention", Label: "Retention", Icon: "⌛"},
|
||||
{Key: "accounts", Label: "Accounts", Icon: "☺"},
|
||||
{Key: "api", Label: "API", Icon: "{ }"},
|
||||
{Key: "storage", Label: "Storage", Icon: "▥"},
|
||||
{Key: "workers", Label: "Workers", Icon: "⚙"},
|
||||
}
|
||||
}
|
||||
|
||||
func settingsCategoryLabel(key string) string {
|
||||
for _, meta := range settingsCategoryMeta() {
|
||||
if meta.Key == key {
|
||||
return meta.Label
|
||||
}
|
||||
}
|
||||
return "General"
|
||||
}
|
||||
|
||||
func settingsCategoryRank(key string) int {
|
||||
for index, meta := range settingsCategoryMeta() {
|
||||
if meta.Key == key {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return len(settingsCategoryMeta()) + 1
|
||||
}
|
||||
|
||||
func settingsCategoryForKey(key string) string {
|
||||
switch key {
|
||||
case config.SettingGuestUploadsEnabled, config.SettingDefaultUserMaxFileBytes, config.SettingDefaultUserMaxBoxBytes, config.SettingGlobalMaxFileSizeBytes, config.SettingGlobalMaxBoxSizeBytes:
|
||||
return "uploads"
|
||||
case config.SettingZipDownloadsEnabled, config.SettingOneTimeDownloadsEnabled, config.SettingOneTimeDownloadExpirySecs, config.SettingRenewOnDownloadEnabled:
|
||||
return "downloads"
|
||||
case config.SettingRenewOnAccessEnabled, config.SettingDefaultGuestExpirySecs, config.SettingMaxGuestExpirySecs, config.SettingOneTimeDownloadRetryFail:
|
||||
return "retention"
|
||||
case config.SettingSessionTTLSeconds:
|
||||
return "accounts"
|
||||
case config.SettingAPIEnabled:
|
||||
return "api"
|
||||
case config.SettingDataDir:
|
||||
return "storage"
|
||||
case config.SettingBoxPollIntervalMS, config.SettingThumbnailBatchSize, config.SettingThumbnailIntervalSeconds:
|
||||
return "workers"
|
||||
default:
|
||||
return "accounts"
|
||||
}
|
||||
}
|
||||
|
||||
func settingsDescription(key string) string {
|
||||
descriptions := map[string]string{
|
||||
config.SettingGuestUploadsEnabled: "Allow unauthenticated guests to create boxes through the public upload flow.",
|
||||
config.SettingAPIEnabled: "Enable API endpoints used by the browser upload and status workflows.",
|
||||
config.SettingZipDownloadsEnabled: "Allow archive downloads for full boxes when ZIP is supported.",
|
||||
config.SettingOneTimeDownloadsEnabled: "Enable one-time download retention mode for boxes.",
|
||||
config.SettingOneTimeDownloadExpirySecs: "Expiry window, in seconds, for one-time download boxes after upload completion.",
|
||||
config.SettingOneTimeDownloadRetryFail: "When enabled by environment, failed one-time ZIP writes leave the box retryable.",
|
||||
config.SettingRenewOnAccessEnabled: "Extend retention when a box page is viewed.",
|
||||
config.SettingRenewOnDownloadEnabled: "Extend retention when file or ZIP downloads happen.",
|
||||
config.SettingDefaultGuestExpirySecs: "Default retention presented to guest uploads.",
|
||||
config.SettingMaxGuestExpirySecs: "Maximum retention guests may request.",
|
||||
config.SettingGlobalMaxFileSizeBytes: "Global single-file upload ceiling applied to future requests across the whole app.",
|
||||
config.SettingGlobalMaxBoxSizeBytes: "Global total box size ceiling applied to future requests across the whole app.",
|
||||
config.SettingDefaultUserMaxFileBytes: "Default per-user file size ceiling used by future account-aware flows.",
|
||||
config.SettingDefaultUserMaxBoxBytes: "Default per-user box size ceiling used by future account-aware flows.",
|
||||
config.SettingSessionTTLSeconds: "Lifetime for authenticated browser sessions, including admin session cookies.",
|
||||
config.SettingBoxPollIntervalMS: "Browser polling cadence for box status refreshes.",
|
||||
config.SettingThumbnailBatchSize: "How many thumbnail jobs the worker handles per batch.",
|
||||
config.SettingThumbnailIntervalSeconds: "Delay between thumbnail worker passes.",
|
||||
config.SettingDataDir: "Root data path. Locked because moving storage roots live is risky.",
|
||||
}
|
||||
return descriptions[key]
|
||||
}
|
||||
258
lib/server/admin_settings_test.go
Normal file
258
lib/server/admin_settings_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"warpbox/lib/config"
|
||||
)
|
||||
|
||||
func TestAdminSettingsRequiresAuth(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected redirect, got %d", response.Code)
|
||||
}
|
||||
if location := response.Header().Get("Location"); location != "/admin/login" {
|
||||
t.Fatalf("expected login redirect, got %q", location)
|
||||
}
|
||||
if app == nil {
|
||||
t.Fatal("expected app setup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsPageRenders(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "WarpBox Settings") {
|
||||
t.Fatalf("expected settings page title, got %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "WARPBOX_API_ENABLED") {
|
||||
t.Fatalf("expected API env var in page body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsExportIncludesCurrentValues(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", response.Code)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Format string `json:"format"`
|
||||
Settings map[string]string `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if payload.Format != "warpbox.settings.export.v1" {
|
||||
t.Fatalf("unexpected export format: %q", payload.Format)
|
||||
}
|
||||
if payload.Settings[config.SettingAPIEnabled] != "false" {
|
||||
t.Fatalf("expected api_enabled to reflect environment false, got %q", payload.Settings[config.SettingAPIEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsSavePersistsEditableOverrides(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"api_enabled":"true","box_poll_interval_ms":"6000"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !app.config.APIEnabled {
|
||||
t.Fatal("expected APIEnabled override to be applied")
|
||||
}
|
||||
if app.config.BoxPollIntervalMS != 6000 {
|
||||
t.Fatalf("expected poll interval override, got %d", app.config.BoxPollIntervalMS)
|
||||
}
|
||||
|
||||
overrides, err := config.ReadAdminSettingsOverrides(app.settingsOverridesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAdminSettingsOverrides returned error: %v", err)
|
||||
}
|
||||
if overrides[config.SettingAPIEnabled] != "true" {
|
||||
t.Fatalf("expected persisted API override, got %#v", overrides)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsSaveRejectsLockedSetting(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/save", strings.NewReader(`{"values":{"data_dir":"./other"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsImportSkipsLockedAndUnknownKeys(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/import", strings.NewReader(`{"settings":{"api_enabled":"true","data_dir":"./other","bogus":"x"}}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !app.config.APIEnabled {
|
||||
t.Fatal("expected editable import value to apply")
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if len(payload.Warnings) != 2 {
|
||||
t.Fatalf("expected 2 warnings, got %#v", payload.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSettingsResetUsesBuiltInDefaults(t *testing.T) {
|
||||
app, router := setupAdminSettingsTest(t)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/settings/reset", strings.NewReader(`{}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.AddCookie(authCookie(app))
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !app.config.APIEnabled {
|
||||
t.Fatal("expected reset to built-in defaults to restore APIEnabled=true")
|
||||
}
|
||||
}
|
||||
|
||||
func setupAdminSettingsTest(t *testing.T) (*App, *gin.Engine) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd returned error: %v", err)
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(cwd, "..", ".."))
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatalf("Chdir returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(cwd)
|
||||
})
|
||||
clearAdminSettingsEnv(t)
|
||||
t.Setenv("WARPBOX_DATA_DIR", t.TempDir())
|
||||
t.Setenv("WARPBOX_ADMIN_PASSWORD", "secret")
|
||||
t.Setenv("WARPBOX_ADMIN_ENABLED", "true")
|
||||
t.Setenv("WARPBOX_API_ENABLED", "false")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
t.Fatalf("EnsureDirectories returned error: %v", err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
config: cfg,
|
||||
settingsOverridesPath: filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename),
|
||||
}
|
||||
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadHTMLTemplates returned error: %v", err)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.SetHTMLTemplate(htmlTemplates)
|
||||
admin := router.Group("/admin")
|
||||
admin.GET("/login", app.handleAdminLogin)
|
||||
protected := router.Group("/admin", app.adminAuthMiddleware)
|
||||
protected.GET("/settings", app.handleAdminSettings)
|
||||
protected.GET("/settings/export", app.handleAdminSettingsExport)
|
||||
protected.POST("/settings/save", app.handleAdminSettingsSave)
|
||||
protected.POST("/settings/import", app.handleAdminSettingsImport)
|
||||
protected.POST("/settings/reset", app.handleAdminSettingsReset)
|
||||
return app, router
|
||||
}
|
||||
|
||||
func authCookie(app *App) *http.Cookie {
|
||||
return &http.Cookie{Name: adminSessionCookie, Value: app.adminSessionToken()}
|
||||
}
|
||||
|
||||
func clearAdminSettingsEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range []string{
|
||||
"WARPBOX_DATA_DIR",
|
||||
"WARPBOX_ADMIN_PASSWORD",
|
||||
"WARPBOX_ADMIN_USERNAME",
|
||||
"WARPBOX_ADMIN_EMAIL",
|
||||
"WARPBOX_ADMIN_ENABLED",
|
||||
"WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE",
|
||||
"WARPBOX_ADMIN_COOKIE_SECURE",
|
||||
"WARPBOX_GUEST_UPLOADS_ENABLED",
|
||||
"WARPBOX_API_ENABLED",
|
||||
"WARPBOX_ZIP_DOWNLOADS_ENABLED",
|
||||
"WARPBOX_ONE_TIME_DOWNLOADS_ENABLED",
|
||||
"WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS",
|
||||
"WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE",
|
||||
"WARPBOX_RENEW_ON_ACCESS_ENABLED",
|
||||
"WARPBOX_RENEW_ON_DOWNLOAD_ENABLED",
|
||||
"WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS",
|
||||
"WARPBOX_MAX_GUEST_EXPIRY_SECONDS",
|
||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_MB",
|
||||
"WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES",
|
||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_MB",
|
||||
"WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES",
|
||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_MB",
|
||||
"WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES",
|
||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_MB",
|
||||
"WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES",
|
||||
"WARPBOX_SESSION_TTL_SECONDS",
|
||||
"WARPBOX_BOX_POLL_INTERVAL_MS",
|
||||
"WARPBOX_THUMBNAIL_BATCH_SIZE",
|
||||
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
|
||||
} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/gzip"
|
||||
@@ -13,7 +15,8 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
config *config.Config
|
||||
config *config.Config
|
||||
settingsOverridesPath string
|
||||
}
|
||||
|
||||
func Run(addr string) error {
|
||||
@@ -24,10 +27,18 @@ func Run(addr string) error {
|
||||
if err := cfg.EnsureDirectories(); err != nil {
|
||||
return err
|
||||
}
|
||||
overridesPath := filepath.Join(cfg.DBDir, config.AdminSettingsOverrideFilename)
|
||||
overrides, err := config.ReadAdminSettingsOverrides(overridesPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ApplyOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyBoxstoreRuntimeConfig(cfg)
|
||||
|
||||
app := &App{config: cfg}
|
||||
app := &App{config: cfg, settingsOverridesPath: overridesPath}
|
||||
|
||||
router := gin.Default()
|
||||
htmlTemplates, err := loadHTMLTemplates()
|
||||
@@ -51,12 +62,19 @@ func Run(addr string) error {
|
||||
DirectBoxUpload: app.handleDirectBoxUpload,
|
||||
LegacyUpload: app.handleLegacyUpload,
|
||||
|
||||
AdminLogin: app.handleAdminLogin,
|
||||
AdminLoginPost: app.handleAdminLoginPost,
|
||||
AdminLogout: app.handleAdminLogout,
|
||||
AdminDashboard: app.handleAdminDashboard,
|
||||
AdminAlerts: app.handleAdminAlerts,
|
||||
AdminAuth: app.adminAuthMiddleware,
|
||||
AdminLogin: app.handleAdminLogin,
|
||||
AdminLoginPost: app.handleAdminLoginPost,
|
||||
AdminLogout: app.handleAdminLogout,
|
||||
AdminDashboard: app.handleAdminDashboard,
|
||||
AdminAlerts: app.handleAdminAlerts,
|
||||
AdminBoxes: app.handleAdminBoxes,
|
||||
AdminBoxesAction: app.handleAdminBoxesAction,
|
||||
AdminSettings: app.handleAdminSettings,
|
||||
AdminSettingsExport: app.handleAdminSettingsExport,
|
||||
AdminSettingsSave: app.handleAdminSettingsSave,
|
||||
AdminSettingsImport: app.handleAdminSettingsImport,
|
||||
AdminSettingsReset: app.handleAdminSettingsReset,
|
||||
AdminAuth: app.adminAuthMiddleware,
|
||||
})
|
||||
|
||||
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))
|
||||
@@ -68,7 +86,15 @@ func Run(addr string) error {
|
||||
}
|
||||
|
||||
func loadHTMLTemplates() (*template.Template, error) {
|
||||
tmpl := template.New("")
|
||||
tmpl := template.New("").Funcs(template.FuncMap{
|
||||
"toJSON": func(value any) template.JS {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return template.JS("null")
|
||||
}
|
||||
return template.JS(data)
|
||||
},
|
||||
})
|
||||
for _, pattern := range []string{
|
||||
"templates/*.html",
|
||||
"templates/admin/*.html",
|
||||
|
||||
Reference in New Issue
Block a user