Total boxes
+0
+All stored manifests and legacy boxes
+diff --git a/lib/boxstore/manifest.go b/lib/boxstore/manifest.go index e85f7bd..2e17e55 100644 --- a/lib/boxstore/manifest.go +++ b/lib/boxstore/manifest.go @@ -153,6 +153,43 @@ func RenewManifest(boxID string, seconds int64) (models.BoxManifest, error) { manifest.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second) return manifest, writeManifestUnlocked(boxID, manifest) } + +func ExpireBox(boxID string) (models.BoxManifest, error) { + manifestMu.Lock() + defer manifestMu.Unlock() + + manifest, err := readManifestUnlocked(boxID) + if err != nil { + return manifest, err + } + manifest.ExpiresAt = time.Now().UTC().Add(-time.Second) + return manifest, writeManifestUnlocked(boxID, manifest) +} + +func BumpBoxExpiry(boxID string, delta time.Duration) (models.BoxManifest, error) { + manifestMu.Lock() + defer manifestMu.Unlock() + + manifest, err := readManifestUnlocked(boxID) + if err != nil { + return manifest, err + } + if delta <= 0 { + return manifest, fmt.Errorf("Invalid bump duration") + } + if manifest.OneTimeDownload { + return manifest, fmt.Errorf("One-time boxes cannot be extended") + } + + base := manifest.ExpiresAt + now := time.Now().UTC() + if base.IsZero() || base.Before(now) { + base = now + } + manifest.ExpiresAt = base.Add(delta) + return manifest, writeManifestUnlocked(boxID, manifest) +} + func reconcileManifest(boxID string) (models.BoxManifest, error) { manifestMu.Lock() defer manifestMu.Unlock() diff --git a/lib/boxstore/store_test.go b/lib/boxstore/store_test.go index 8fe0192..2228acd 100644 --- a/lib/boxstore/store_test.go +++ b/lib/boxstore/store_test.go @@ -204,3 +204,57 @@ func TestBoxPasswordUsesBcryptAndVerifiesLegacy(t *testing.T) { t.Fatal("expected legacy password hash to verify") } } + +func TestExpireBoxMarksManifestExpired(t *testing.T) { + restoreUploadRoot := UploadRoot() + defer SetUploadRoot(restoreUploadRoot) + SetUploadRoot(t.TempDir()) + + boxID := "0123456789abcdef0123456789abcdef" + if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + manifest := models.BoxManifest{ + CreatedAt: time.Now().UTC().Add(-time.Hour), + ExpiresAt: time.Now().UTC().Add(time.Hour), + } + if err := WriteManifest(boxID, manifest); err != nil { + t.Fatalf("WriteManifest returned error: %v", err) + } + + expired, err := ExpireBox(boxID) + if err != nil { + t.Fatalf("ExpireBox returned error: %v", err) + } + if !expired.ExpiresAt.Before(time.Now().UTC()) { + t.Fatalf("expected expired manifest time in past, got %s", expired.ExpiresAt) + } +} + +func TestBumpBoxExpiryExtendsFutureExpiry(t *testing.T) { + restoreUploadRoot := UploadRoot() + defer SetUploadRoot(restoreUploadRoot) + SetUploadRoot(t.TempDir()) + + boxID := "fedcba9876543210fedcba9876543210" + if err := os.MkdirAll(BoxPath(boxID), 0755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + base := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + manifest := models.BoxManifest{ + CreatedAt: time.Now().UTC().Add(-time.Hour), + ExpiresAt: base, + } + if err := WriteManifest(boxID, manifest); err != nil { + t.Fatalf("WriteManifest returned error: %v", err) + } + + bumped, err := BumpBoxExpiry(boxID, 24*time.Hour) + if err != nil { + t.Fatalf("BumpBoxExpiry returned error: %v", err) + } + expected := base.Add(24 * time.Hour) + if bumped.ExpiresAt.Before(expected.Add(-time.Second)) || bumped.ExpiresAt.After(expected.Add(time.Second)) { + t.Fatalf("expected bumped expiry near %s, got %s", expected, bumped.ExpiresAt) + } +} diff --git a/lib/config/config_test.go b/lib/config/config_test.go index aebfaa6..c32f84f 100644 --- a/lib/config/config_test.go +++ b/lib/config/config_test.go @@ -145,8 +145,14 @@ func TestSettingsOverrideValidation(t *testing.T) { if err := cfg.ApplyOverride(SettingDefaultGuestExpirySecs, "-1"); err == nil { t.Fatal("expected negative expiry override to fail") } - if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil { - t.Fatal("expected hard limit override to fail") + if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err != nil { + t.Fatalf("expected global max file size override to succeed, got %v", err) + } + if cfg.GlobalMaxFileSizeBytes != 1 { + t.Fatalf("expected global max file size override to apply, got %d", cfg.GlobalMaxFileSizeBytes) + } + if err := cfg.ApplyOverride(SettingDataDir, "/tmp/elsewhere"); err == nil { + t.Fatal("expected data_dir override to remain locked") } } diff --git a/lib/config/definitions.go b/lib/config/definitions.go index 46f4ff9..66d014d 100644 --- a/lib/config/definitions.go +++ b/lib/config/definitions.go @@ -12,8 +12,8 @@ var Definitions = []SettingDefinition{ {Key: SettingRenewOnDownloadEnabled, EnvName: "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", Label: "Renew on download enabled", Type: SettingTypeBool, Editable: true}, {Key: SettingDefaultGuestExpirySecs, EnvName: "WARPBOX_DEFAULT_GUEST_EXPIRY_SECONDS", Label: "Default guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingMaxGuestExpirySecs, EnvName: "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", Label: "Max guest expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0}, - {Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0}, - {Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: false, HardLimit: true, Minimum: 0}, + {Key: SettingGlobalMaxFileSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_FILE_SIZE_BYTES", Label: "Global max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0}, + {Key: SettingGlobalMaxBoxSizeBytes, EnvName: "WARPBOX_GLOBAL_MAX_BOX_SIZE_BYTES", Label: "Global max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingDefaultUserMaxFileBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_FILE_SIZE_BYTES", Label: "Default user max file size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingDefaultUserMaxBoxBytes, EnvName: "WARPBOX_DEFAULT_USER_MAX_BOX_SIZE_BYTES", Label: "Default user max box size bytes", Type: SettingTypeInt64, Editable: true, Minimum: 0}, {Key: SettingSessionTTLSeconds, EnvName: "WARPBOX_SESSION_TTL_SECONDS", Label: "Session TTL seconds", Type: SettingTypeInt64, Editable: true, Minimum: 60}, diff --git a/lib/config/load.go b/lib/config/load.go index 3859d3d..92b353d 100644 --- a/lib/config/load.go +++ b/lib/config/load.go @@ -29,6 +29,7 @@ func Load() (*Config, error) { ThumbnailIntervalSeconds: 30, sources: make(map[string]Source), values: make(map[string]string), + defaults: make(map[string]string), } // Config precedence: defaults -> env -> overrides. @@ -152,25 +153,32 @@ func (cfg *Config) EnsureDirectories() error { return nil } func (cfg *Config) captureDefaults() { - cfg.setValue(SettingDataDir, cfg.DataDir, SourceDefault) - cfg.setValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled), SourceDefault) - cfg.setValue(SettingAPIEnabled, formatBool(cfg.APIEnabled), SourceDefault) - cfg.setValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled), SourceDefault) - cfg.setValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled), SourceDefault) - cfg.setValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10), SourceDefault) - cfg.setValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure), SourceDefault) - cfg.setValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled), SourceDefault) - cfg.setValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled), SourceDefault) - cfg.setValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10), SourceDefault) - cfg.setValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10), SourceDefault) - cfg.setValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10), SourceDefault) - cfg.setValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10), SourceDefault) - cfg.setValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10), SourceDefault) - cfg.setValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10), SourceDefault) - cfg.setValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10), SourceDefault) - cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault) - cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault) - cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault) + cfg.captureDefaultValue(SettingDataDir, cfg.DataDir) + cfg.captureDefaultValue(SettingGuestUploadsEnabled, formatBool(cfg.GuestUploadsEnabled)) + cfg.captureDefaultValue(SettingAPIEnabled, formatBool(cfg.APIEnabled)) + cfg.captureDefaultValue(SettingZipDownloadsEnabled, formatBool(cfg.ZipDownloadsEnabled)) + cfg.captureDefaultValue(SettingOneTimeDownloadsEnabled, formatBool(cfg.OneTimeDownloadsEnabled)) + cfg.captureDefaultValue(SettingOneTimeDownloadExpirySecs, strconv.FormatInt(cfg.OneTimeDownloadExpirySeconds, 10)) + cfg.captureDefaultValue(SettingOneTimeDownloadRetryFail, formatBool(cfg.OneTimeDownloadRetryOnFailure)) + cfg.captureDefaultValue(SettingRenewOnAccessEnabled, formatBool(cfg.RenewOnAccessEnabled)) + cfg.captureDefaultValue(SettingRenewOnDownloadEnabled, formatBool(cfg.RenewOnDownloadEnabled)) + cfg.captureDefaultValue(SettingDefaultGuestExpirySecs, strconv.FormatInt(cfg.DefaultGuestExpirySeconds, 10)) + cfg.captureDefaultValue(SettingMaxGuestExpirySecs, strconv.FormatInt(cfg.MaxGuestExpirySeconds, 10)) + cfg.captureDefaultValue(SettingGlobalMaxFileSizeBytes, strconv.FormatInt(cfg.GlobalMaxFileSizeBytes, 10)) + cfg.captureDefaultValue(SettingGlobalMaxBoxSizeBytes, strconv.FormatInt(cfg.GlobalMaxBoxSizeBytes, 10)) + cfg.captureDefaultValue(SettingDefaultUserMaxFileBytes, strconv.FormatInt(cfg.DefaultUserMaxFileSizeBytes, 10)) + cfg.captureDefaultValue(SettingDefaultUserMaxBoxBytes, strconv.FormatInt(cfg.DefaultUserMaxBoxSizeBytes, 10)) + cfg.captureDefaultValue(SettingSessionTTLSeconds, strconv.FormatInt(cfg.SessionTTLSeconds, 10)) + cfg.captureDefaultValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS)) + cfg.captureDefaultValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize)) + cfg.captureDefaultValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds)) +} + +func (cfg *Config) captureDefaultValue(key string, value string) { + cfg.setValue(key, value, SourceDefault) + if cfg.defaults != nil { + cfg.defaults[key] = value + } } func (cfg *Config) applyStringEnv(key string, name string, target *string) error { diff --git a/lib/config/models.go b/lib/config/models.go index dcfb68f..9fbd0dc 100644 --- a/lib/config/models.go +++ b/lib/config/models.go @@ -95,6 +95,7 @@ type Config struct { ThumbnailBatchSize int ThumbnailIntervalSeconds int - sources map[string]Source - values map[string]string + sources map[string]Source + values map[string]string + defaults map[string]string } diff --git a/lib/config/override_store.go b/lib/config/override_store.go new file mode 100644 index 0000000..0ce479f --- /dev/null +++ b/lib/config/override_store.go @@ -0,0 +1,68 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "time" +) + +const AdminSettingsOverrideFilename = "admin_settings_overrides.json" + +type adminSettingsOverrideFile struct { + Format string `json:"format"` + SavedAt string `json:"saved_at"` + Overrides map[string]string `json:"overrides"` +} + +func ReadAdminSettingsOverrides(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + return nil, err + } + + var payload adminSettingsOverrideFile + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + if payload.Overrides == nil { + return map[string]string{}, nil + } + return payload.Overrides, nil +} + +func WriteAdminSettingsOverrides(path string, overrides map[string]string) error { + if overrides == nil { + overrides = map[string]string{} + } + + keys := make([]string, 0, len(overrides)) + for key := range overrides { + keys = append(keys, key) + } + sort.Strings(keys) + + normalized := make(map[string]string, len(overrides)) + for _, key := range keys { + normalized[key] = overrides[key] + } + + payload := adminSettingsOverrideFile{ + Format: "warpbox.admin.settings.overrides.v1", + SavedAt: time.Now().UTC().Format(time.RFC3339), + Overrides: normalized, + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} diff --git a/lib/config/overrides.go b/lib/config/overrides.go index 5e4395a..db3f403 100644 --- a/lib/config/overrides.go +++ b/lib/config/overrides.go @@ -76,6 +76,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) { cfg.MaxGuestExpirySeconds = value case SettingOneTimeDownloadExpirySecs: cfg.OneTimeDownloadExpirySeconds = value + case SettingGlobalMaxFileSizeBytes: + cfg.GlobalMaxFileSizeBytes = value + case SettingGlobalMaxBoxSizeBytes: + cfg.GlobalMaxBoxSizeBytes = value case SettingDefaultUserMaxFileBytes: cfg.DefaultUserMaxFileSizeBytes = value case SettingDefaultUserMaxBoxBytes: @@ -113,3 +117,10 @@ func (cfg *Config) sourceFor(key string) Source { } return source } + +func (cfg *Config) DefaultValue(key string) string { + if cfg.defaults == nil { + return "" + } + return cfg.defaults[key] +} diff --git a/lib/routing/routes.go b/lib/routing/routes.go index d36a481..77346f9 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -17,12 +17,19 @@ type Handlers struct { DirectBoxUpload gin.HandlerFunc LegacyUpload gin.HandlerFunc - AdminLogin gin.HandlerFunc - AdminLoginPost gin.HandlerFunc - AdminLogout gin.HandlerFunc - AdminDashboard gin.HandlerFunc - AdminAlerts gin.HandlerFunc - AdminAuth gin.HandlerFunc + AdminLogin gin.HandlerFunc + AdminLoginPost gin.HandlerFunc + AdminLogout gin.HandlerFunc + AdminDashboard gin.HandlerFunc + AdminAlerts gin.HandlerFunc + AdminBoxes gin.HandlerFunc + AdminBoxesAction gin.HandlerFunc + AdminSettings gin.HandlerFunc + AdminSettingsExport gin.HandlerFunc + AdminSettingsSave gin.HandlerFunc + AdminSettingsImport gin.HandlerFunc + AdminSettingsReset gin.HandlerFunc + AdminAuth gin.HandlerFunc } func Register(router *gin.Engine, handlers Handlers) { @@ -52,4 +59,11 @@ func Register(router *gin.Engine, handlers Handlers) { protected := router.Group("/admin", handlers.AdminAuth) protected.GET("/dashboard", handlers.AdminDashboard) protected.GET("/alerts", handlers.AdminAlerts) + protected.GET("/boxes", handlers.AdminBoxes) + protected.POST("/boxes/actions", handlers.AdminBoxesAction) + protected.GET("/settings", handlers.AdminSettings) + protected.GET("/settings/export", handlers.AdminSettingsExport) + protected.POST("/settings/save", handlers.AdminSettingsSave) + protected.POST("/settings/import", handlers.AdminSettingsImport) + protected.POST("/settings/reset", handlers.AdminSettingsReset) } diff --git a/lib/server/admin_boxes.go b/lib/server/admin_boxes.go new file mode 100644 index 0000000..8e79227 --- /dev/null +++ b/lib/server/admin_boxes.go @@ -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() + } +} diff --git a/lib/server/admin_settings.go b/lib/server/admin_settings.go new file mode 100644 index 0000000..3966b98 --- /dev/null +++ b/lib/server/admin_settings.go @@ -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] +} diff --git a/lib/server/admin_settings_test.go b/lib/server/admin_settings_test.go new file mode 100644 index 0000000..a8c2ec7 --- /dev/null +++ b/lib/server/admin_settings_test.go @@ -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, "") + } +} diff --git a/lib/server/server.go b/lib/server/server.go index 127bef2..11ad781 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -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", diff --git a/static/css/boxes.css b/static/css/boxes.css new file mode 100644 index 0000000..fe9a051 --- /dev/null +++ b/static/css/boxes.css @@ -0,0 +1,501 @@ +.boxes-page-body { + display: grid; + gap: 10px; +} + +.boxes-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.boxes-stat-card { + min-width: 0; + padding: 8px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0; +} + +.boxes-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); } +.boxes-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); } +.boxes-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); } +.boxes-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); } + +.boxes-stat-label { + margin: 0 0 4px; + font-size: 12px; + line-height: 12px; + text-transform: uppercase; + color: #333333; +} + +.boxes-stat-value { + margin: 0; + font-size: 24px; + line-height: 24px; + font-weight: bold; +} + +.boxes-stat-note { + margin: 6px 0 0; + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + color: #222222; + background: rgba(255,255,255,.65); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a0a0a0; + border-bottom: 1px solid #a0a0a0; + font-size: 12px; + line-height: 12px; +} + +.boxes-hero-note { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + color: #000000; + background: #ffffcc; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a08000; + border-bottom: 1px solid #a08000; +} + +.boxes-hero-note strong { + font-size: 13px; + line-height: 13px; +} + +.boxes-hero-note span { + font-size: 13px; + line-height: 15px; +} + +.boxes-hero-tags { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.boxes-hero-tag, +.boxes-flag { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + color: #222222; + background: #f1f1f1; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #b0b0b0; + border-bottom: 1px solid #b0b0b0; + font-size: 12px; + line-height: 12px; +} + +.boxes-content-grid { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, .75fr); + gap: 10px; + min-height: 0; +} + +.boxes-column { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; +} + +.boxes-panel { + display: flex; + flex-direction: column; + min-height: 0; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08); +} + +.boxes-files-panel { + min-height: 300px; +} + +.boxes-panel-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 34px; + padding: 6px 8px; + background: #dfdfdf; + border-bottom: 1px solid #b0b0b0; + box-shadow: inset 1px 1px 0 #f7f7f7; +} + +.boxes-panel-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + min-height: 22px; + font-weight: bold; + font-size: 15px; + line-height: 15px; +} + +.boxes-panel-sub { + color: #444444; + font-size: 12px; + line-height: 12px; + font-weight: normal; +} + +.boxes-panel-tools { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.boxes-panel-body { + flex: 1 1 auto; + min-height: 0; + padding: 10px; + overflow: hidden; + background-color: #ffffff; + background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)); +} + +.boxes-tool-button, +.boxes-page-button, +.boxes-action-button, +.boxes-row-button { + min-width: 62px; + height: 24px; + padding: 0 8px; + font-size: 12px; + line-height: 12px; +} + +.boxes-tool-button.is-danger, +.boxes-action-button.is-danger { + color: #ffffff; + background: #800000; +} + +.boxes-toolbar-grid { + display: grid; + grid-template-columns: minmax(200px, 1.3fr) repeat(4, minmax(110px, .55fr)); + gap: 8px; + margin-bottom: 8px; +} + +.boxes-input, +.boxes-select { + width: 100%; + min-width: 0; + height: 28px; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 4px 6px; + font-family: inherit; + font-size: 13px; +} + +.boxes-table-wrap { + width: 100%; + min-height: 0; + height: 460px; + overflow-y: auto; + overflow-x: hidden; + background: #ffffff; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +.boxes-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: 12px; + line-height: 14px; + color: #000000; +} + +.boxes-table thead th { + position: sticky; + top: 0; + z-index: 2; + padding: 6px; + text-align: left; + background: #dfdfdf; + border-bottom: 1px solid #b0b0b0; + box-shadow: inset 0 1px 0 #ffffff; +} + +.boxes-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); } +.boxes-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); } +.boxes-table tbody tr:hover { background: #d8e5f8; } +.boxes-table tbody tr.is-selected { background: #c5dcff; } + +.boxes-table td { + padding: 6px; + border-bottom: 1px solid #e1e1e1; + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.boxes-col-check { width: 34px; } +.boxes-col-id { width: 190px; } +.boxes-col-status { width: 84px; } +.boxes-col-files { width: 58px; } +.boxes-col-size { width: 76px; } +.boxes-col-retention { width: 96px; } +.boxes-col-expires { width: 126px; } +.boxes-col-actions { width: 98px; } + +.boxes-status-pill { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + color: #222222; + background: #f1f1f1; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #b0b0b0; + border-bottom: 1px solid #b0b0b0; + font-size: 12px; + line-height: 12px; +} + +.boxes-status-pill.ready { background: #def2e0; } +.boxes-status-pill.uploading { background: #fff1c9; } +.boxes-status-pill.attention { background: #ffe2bf; } +.boxes-status-pill.expired { background: #ffdcdc; } +.boxes-status-pill.consumed { background: #ead7ff; } +.boxes-status-pill.legacy { background: #ececec; } + +.boxes-flags-cell, +.boxes-action-cell { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + min-width: 0; + overflow: hidden; +} + +.boxes-action-cell a { + text-decoration: none; +} + +.boxes-empty-state { + padding: 24px 12px; + text-align: center; + color: #444444; + background: linear-gradient(180deg, rgba(255,255,255,.95), rgba(242,242,242,.95)); + font-size: 14px; + line-height: 16px; +} + +.boxes-footer-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; + font-size: 12px; + line-height: 12px; +} + +.boxes-pagination { + display: flex; + align-items: center; + gap: 8px; +} + +.boxes-detail-body { + display: grid; + gap: 10px; +} + +.boxes-info-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.boxes-info-item { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 6px 8px; + background: #f5f5f5; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #c0c0c0; + border-bottom: 1px solid #c0c0c0; +} + +.boxes-info-item strong { + font-size: 13px; + line-height: 13px; +} + +.boxes-info-item span { + min-width: 0; + color: #222222; + word-break: break-word; +} + +.boxes-action-stack { + display: grid; + gap: 6px; +} + +.boxes-action-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.boxes-action-button { + width: 100%; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +.boxes-file-list { + display: grid; + gap: 8px; + min-height: 0; + height: 320px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 2px; +} + +.boxes-column:first-child > .boxes-panel { + flex: 1 1 auto; +} + +.boxes-column:first-child > .boxes-panel > .boxes-panel-body { + display: flex; + flex-direction: column; +} + +.boxes-column:first-child .boxes-table-wrap { + flex: 1 1 auto; + height: auto; + min-height: 560px; +} + +.boxes-file-card { + display: grid; + gap: 6px; + padding: 8px; + background: #f8f8f8; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #c0c0c0; + border-bottom: 1px solid #c0c0c0; +} + +.boxes-file-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.boxes-file-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + line-height: 13px; + font-weight: bold; +} + +.boxes-file-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + color: #333333; + font-size: 12px; + line-height: 12px; +} + +.boxes-file-link { + text-decoration: none; +} + +@media (max-width: 1100px) { + .boxes-summary-grid, + .boxes-content-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .boxes-column-side { + grid-column: 1 / -1; + } + + .boxes-toolbar-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .boxes-summary-grid, + .boxes-content-grid, + .boxes-toolbar-grid, + .boxes-action-grid { + grid-template-columns: minmax(0, 1fr); + } + + .boxes-hero-note, + .boxes-footer-bar { + align-items: flex-start; + flex-direction: column; + } + + .boxes-table-wrap { + height: 420px; + } + + .boxes-column:first-child .boxes-table-wrap { + min-height: 420px; + } +} diff --git a/static/css/settings.css b/static/css/settings.css new file mode 100644 index 0000000..4d6feab --- /dev/null +++ b/static/css/settings.css @@ -0,0 +1,510 @@ +.settings-page-body { + display: grid; + gap: 10px; +} + +.settings-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.settings-stat-card { + min-width: 0; + padding: 8px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0; +} + +.settings-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); } +.settings-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); } +.settings-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); } +.settings-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); } + +.settings-stat-label { + margin: 0 0 4px; + font-size: 12px; + line-height: 12px; + text-transform: uppercase; + color: #333333; +} + +.settings-stat-value { + margin: 0; + font-size: 24px; + line-height: 24px; + font-weight: bold; +} + +.settings-stat-note { + margin: 6px 0 0; + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + color: #222222; + background: rgba(255,255,255,.65); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a0a0a0; + border-bottom: 1px solid #a0a0a0; + font-size: 12px; + line-height: 12px; +} + +.settings-main-grid { + display: grid; + grid-template-columns: 238px minmax(0, 1fr); + gap: 10px; + min-height: 0; +} + +.settings-sidebar-panel { + min-width: 0; +} + +.settings-sidebar { + position: sticky; + top: 48px; +} + +.settings-workbench { + display: grid; + gap: 10px; + min-width: 0; +} + +.settings-panel, +.settings-hero-panel { + min-width: 0; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + box-shadow: inset 1px 1px 0 rgba(255,255,255,.7), inset -1px -1px 0 rgba(0,0,0,.08); +} + +.settings-panel { + display: flex; + flex-direction: column; +} + +.settings-panel-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 34px; + padding: 6px 8px; + background: #dfdfdf; + border-bottom: 1px solid #b0b0b0; + box-shadow: inset 1px 1px 0 #f7f7f7; +} + +.settings-panel-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + min-height: 22px; + font-weight: bold; + font-size: 15px; + line-height: 15px; +} + +.settings-panel-sub { + color: #444444; + font-size: 12px; + line-height: 12px; + font-weight: normal; +} + +.settings-panel-tools { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.settings-panel-body { + flex: 1 1 auto; + min-height: 0; + padding: 10px; + overflow: hidden; + background-color: #ffffff; + background-image: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)); +} + +.settings-hero-panel { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(280px, .8fr); + gap: 10px; + padding: 10px; + background-image: linear-gradient(180deg, rgba(255,255,255,.92), rgba(238,238,238,.58)); +} + +.settings-hero-copy h2 { + margin: 0 0 6px; + font-size: 18px; + line-height: 18px; +} + +.settings-hero-copy p { + margin: 0; + color: #222222; + font-size: 13px; + line-height: 16px; +} + +.settings-hero-legend { + display: grid; + gap: 6px; +} + +.settings-legend-row { + display: flex; + align-items: center; + gap: 6px; + color: #222222; + font-size: 12px; + line-height: 12px; +} + +.settings-search { + display: grid; + gap: 6px; + margin-bottom: 8px; +} + +.settings-search label { + font-weight: bold; + font-size: 13px; + line-height: 13px; +} + +.settings-input, +.settings-select { + width: 100%; + min-width: 0; + color: #000000; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + padding: 4px 6px; + font-family: inherit; + font-size: 13px; +} + +.settings-input, +.settings-select { + height: 28px; +} + +.settings-category-list { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.settings-category-button { + width: 100%; + min-height: 30px; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 7px; + padding: 4px 6px; + color: #000000; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + font-family: inherit; + text-align: left; +} + +.settings-category-button.is-active { + color: #ffffff; + background: #000078; + border-top-color: #000000; + border-left-color: #000000; + border-right-color: #ffffff; + border-bottom-color: #ffffff; +} + +.settings-category-count, +.settings-dirty-chip, +.settings-badge { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + color: #222222; + background: #f1f1f1; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #b0b0b0; + border-bottom: 1px solid #b0b0b0; + font-size: 12px; + line-height: 12px; +} + +.settings-category-button.is-active .settings-category-count { + color: #000000; + background: #ffffcc; +} + +.settings-dirty-chip { + min-width: 78px; + justify-content: center; +} + +.settings-dirty-chip.is-dirty { + background: #ffffcc; + border: 3px solid transparent; + border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3; +} + +.badge-default { background: #ececec; } +.badge-env { background: #c7d8f2; } +.badge-db { background: #d2efcf; } +.badge-hard { background: #ffd9d9; } + +.settings-tool-button, +.settings-mini-button, +.settings-popup-close { + min-width: 64px; + height: 24px; + padding: 0 8px; + font-size: 12px; + line-height: 12px; +} + +.settings-action-summary { + margin-bottom: 8px; + padding: 8px; + color: #000000; + background: #ffffcc; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #a08000; + border-bottom: 1px solid #a08000; + font-size: 12px; + line-height: 15px; +} + +.settings-groups { + display: grid; + gap: 10px; + min-height: 0; + max-height: 700px; + overflow-y: auto; + overflow-x: hidden; + padding-right: 2px; +} + +.settings-group { + display: grid; + gap: 0; +} + +.settings-group[hidden] { + display: none; +} + +.settings-group-title { + min-height: 28px; + padding: 6px 8px; + color: #000000; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + font-weight: bold; + font-size: 14px; + line-height: 14px; +} + +.settings-table-wrap { + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; + background: #ffffff; +} + +.settings-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + color: #000000; + font-size: 12px; + line-height: 14px; +} + +.settings-table th, +.settings-table td { + padding: 6px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid #e1e1e1; +} + +.settings-table th { + position: sticky; + top: 0; + z-index: 2; + background: #dfdfdf; + box-shadow: inset 0 1px 0 #ffffff; +} + +.settings-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); } +.settings-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); } +.setting-row.is-locked { color: #555555; background: #efefef; } +.setting-row.is-hidden { display: none; } +.setting-row.is-invalid { background: #fff1c9; } + +.setting-meta { + display: grid; + gap: 4px; +} + +.setting-meta strong { + font-size: 13px; + line-height: 13px; +} + +.setting-meta code { + color: #1b325f; + font-size: 11px; + line-height: 12px; + word-break: break-word; +} + +.setting-control { + display: grid; + gap: 4px; +} + +.setting-hint { + color: #444444; + font-size: 11px; + line-height: 13px; +} + +.setting-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.settings-modal-backdrop { + position: fixed; + inset: 0; + display: none; + background: rgba(0,0,0,.35); + z-index: 90; +} + +.settings-modal-backdrop.is-visible { + display: block; +} + +.settings-popup { + position: fixed; + left: 50%; + top: 50%; + width: min(520px, calc(100vw - 24px)); + display: none; + transform: translate(-50%, -50%); + color: #000000; + background: var(--w98-gray); + border-top: 2px solid #ffffff; + border-left: 2px solid #ffffff; + border-right: 2px solid #000000; + border-bottom: 2px solid #000000; + box-shadow: 6px 6px 0 rgba(0,0,0,.35); + z-index: 95; +} + +.settings-popup.is-visible { + display: block; +} + +.settings-popup-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + padding: 4px 6px; + color: #ffffff; + background: #000078; +} + +.settings-popup-body { + padding: 10px; + background: #f5f5f5; + font-size: 13px; + line-height: 16px; +} + +.settings-popup-body p, +.settings-popup-body ul { + margin: 0 0 8px; +} + +@media (max-width: 1100px) { + .settings-summary-grid, + .settings-main-grid, + .settings-hero-panel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .settings-sidebar-panel, + .settings-workbench { + grid-column: 1 / -1; + } + + .settings-sidebar { + position: static; + } +} + +@media (max-width: 720px) { + .settings-summary-grid, + .settings-main-grid, + .settings-hero-panel { + grid-template-columns: minmax(0, 1fr); + } + + .settings-panel-header { + align-items: flex-start; + flex-direction: column; + } + + .settings-category-list { + grid-template-columns: minmax(0, 1fr); + } + + .settings-table-wrap { + overflow-x: auto; + } + + .settings-table { + min-width: 760px; + } +} diff --git a/static/js/admin/boxes.js b/static/js/admin/boxes.js new file mode 100644 index 0000000..0c88596 --- /dev/null +++ b/static/js/admin/boxes.js @@ -0,0 +1,526 @@ +(() => { + const menuController = window.WarpBoxUI?.bindMenuBar?.() || { + close() { + document.querySelectorAll(".menu-item.is-open").forEach((item) => { + item.classList.remove("is-open"); + item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); + }); + } + }; + + const toastTarget = document.getElementById("toast"); + const dataNode = document.getElementById("boxes-data"); + const tableBody = document.getElementById("boxes-table-body"); + const emptyState = document.getElementById("boxes-empty-state"); + const searchInput = document.getElementById("boxes-search"); + const statusFilter = document.getElementById("boxes-status-filter"); + const flagFilter = document.getElementById("boxes-flag-filter"); + const sortFilter = document.getElementById("boxes-sort"); + const pageSizeFilter = document.getElementById("boxes-page-size"); + const selectAll = document.getElementById("boxes-select-all"); + const prevPageButton = document.getElementById("boxes-prev-page"); + const nextPageButton = document.getElementById("boxes-next-page"); + const pageLabel = document.getElementById("boxes-page-label"); + const rangeLabel = document.getElementById("boxes-range-label"); + const selectedLabel = document.getElementById("boxes-selected-label"); + const footerSummary = document.getElementById("boxes-footer-summary"); + const detailFileList = document.getElementById("detail-file-list"); + + if (!dataNode || !tableBody || !searchInput || !detailFileList) return; + + const statEls = { + total: document.querySelector("[data-stat-total]"), + ready: document.querySelector("[data-stat-ready]"), + uploading: document.querySelector("[data-stat-uploading]"), + expired: document.querySelector("[data-stat-expired]") + }; + + const detailEls = { + boxId: document.getElementById("detail-box-id"), + status: document.getElementById("detail-status"), + created: document.getElementById("detail-created"), + expires: document.getElementById("detail-expires"), + retention: document.getElementById("detail-retention"), + files: document.getElementById("detail-files"), + size: document.getElementById("detail-size"), + flags: document.getElementById("detail-flags"), + open: document.getElementById("detail-open"), + zip: document.getElementById("detail-zip") + }; + + function showToast(message, type = "info", duration = 2200) { + if (window.WarpBoxUI) { + window.WarpBoxUI.toast(message, type, { target: toastTarget, duration }); + return; + } + if (!toastTarget) return; + toastTarget.textContent = message; + toastTarget.classList.add("is-visible"); + window.setTimeout(() => toastTarget.classList.remove("is-visible"), duration); + } + + function parseData() { + try { + return JSON.parse(dataNode.textContent || "[]"); + } catch (_) { + return []; + } + } + + const state = { + boxes: parseData(), + selected: new Set(), + activeId: null, + page: 1 + }; + + function pageSize() { + return Number(pageSizeFilter.value || 10); + } + + function allBoxes() { + return state.boxes.slice(); + } + + function sortBoxes(boxes) { + const sorted = boxes.slice(); + switch (sortFilter.value) { + case "name": + sorted.sort((a, b) => a.id.localeCompare(b.id)); + break; + case "largest": + sorted.sort((a, b) => compareSizeLabel(a.total_size_label, b.total_size_label)); + break; + case "expires": + sorted.sort((a, b) => compareExpiry(a.expires_at_iso, b.expires_at_iso)); + break; + default: + sorted.sort((a, b) => (b.created_at_iso || "").localeCompare(a.created_at_iso || "")); + } + return sorted; + } + + function compareSizeLabel(left, right) { + return sizeLabelToBytes(right) - sizeLabelToBytes(left); + } + + function sizeLabelToBytes(label) { + const match = String(label || "").trim().match(/^([\d.]+)\s*([KMGT]?i?B|B)$/i); + if (!match) return 0; + const value = Number(match[1]); + const unit = match[2].toUpperCase(); + const map = { B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4 }; + return value * (map[unit] || 1); + } + + function compareExpiry(left, right) { + if (!left && !right) return 0; + if (!left) return 1; + if (!right) return -1; + return left.localeCompare(right); + } + + function filteredBoxes() { + const query = searchInput.value.trim().toLowerCase(); + const status = statusFilter.value; + const flag = flagFilter.value; + + return sortBoxes(allBoxes().filter((box) => { + const matchesSearch = !query || String(box.search_text || "").includes(query); + const matchesStatus = status === "all" || box.status === status; + const matchesFlag = flag === "all" || (box.flags || []).includes(flag); + return matchesSearch && matchesStatus && matchesFlag; + })); + } + + function pagedBoxes(boxes) { + const size = pageSize(); + const pages = Math.max(1, Math.ceil(boxes.length / size)); + if (state.page > pages) state.page = pages; + if (state.page < 1) state.page = 1; + const start = (state.page - 1) * size; + return { + items: boxes.slice(start, start + size), + start, + pages + }; + } + + function selectedBoxes() { + return allBoxes().filter((box) => state.selected.has(box.id)); + } + + function currentActiveBox() { + const boxes = allBoxes(); + return boxes.find((box) => box.id === state.activeId) || null; + } + + function ensureActiveBox(filtered) { + if (filtered.length === 0) { + state.activeId = null; + return null; + } + if (!filtered.some((box) => box.id === state.activeId)) { + state.activeId = filtered[0].id; + } + return filtered.find((box) => box.id === state.activeId) || filtered[0]; + } + + function renderSummary(filtered) { + const total = filtered.length; + const ready = filtered.filter((box) => box.status === "ready").length; + const uploading = filtered.filter((box) => box.status === "uploading").length; + const expired = filtered.filter((box) => box.status === "expired" || box.status === "consumed").length; + statEls.total.textContent = String(total); + statEls.ready.textContent = String(ready); + statEls.uploading.textContent = String(uploading); + statEls.expired.textContent = String(expired); + footerSummary.textContent = `${allBoxes().length} boxes loaded`; + selectedLabel.textContent = `Selected: ${state.selected.size}`; + } + + function renderTable() { + const filtered = filteredBoxes(); + const active = ensureActiveBox(filtered); + const page = pagedBoxes(filtered); + + tableBody.innerHTML = ""; + page.items.forEach((box) => tableBody.appendChild(buildRow(box))); + emptyState.hidden = page.items.length !== 0; + + const startIndex = filtered.length ? page.start + 1 : 0; + const endIndex = page.start + page.items.length; + rangeLabel.textContent = `Showing ${startIndex}-${endIndex} of ${filtered.length}`; + pageLabel.textContent = `Page ${state.page} / ${page.pages}`; + prevPageButton.disabled = state.page <= 1; + nextPageButton.disabled = state.page >= page.pages; + selectAll.checked = page.items.length > 0 && page.items.every((box) => state.selected.has(box.id)); + + renderSummary(filtered); + renderDetails(active); + } + + function buildRow(box) { + const row = document.createElement("tr"); + if (box.id === state.activeId) row.classList.add("is-selected"); + + row.innerHTML = ` +
Reset defaults writes built-in WarpBox defaults as admin overrides for editable settings.
+Environment-only settings stay locked and unchanged.
+ ` + ); + } + + function showRowInfo(row) { + window.WarpBoxUI?.openPopup?.( + row.label, + ` +Environment variable: ${escapeHtml(row.envName || "n/a")}
+Current source: ${escapeHtml(row.badge?.textContent || row.element.dataset.sourceBadge || "default")}
+Description: ${escapeHtml(row.element.dataset.description || "No description available.")}
+ ${row.element.dataset.default ? `Default value: ${escapeHtml(row.element.dataset.default)}
` : ""} + ` + ); + } + + async function runCommand(command) { + switch (command) { + case "save": + await saveChanges(); + return; + case "export": + await exportSettings(); + return; + case "import": + importInput.click(); + return; + case "discard": + discardUnsaved(); + return; + case "show-all": + state.showChangedOnly = false; + state.showLockedOnly = false; + applyFilters(); + showToast("Showing all matching settings"); + return; + case "show-changed": + state.showChangedOnly = !state.showChangedOnly; + if (state.showChangedOnly) state.showLockedOnly = false; + applyFilters(); + showToast(state.showChangedOnly ? "Showing changed settings only" : "Showing all matching settings"); + return; + case "show-locked": + state.showLockedOnly = !state.showLockedOnly; + if (state.showLockedOnly) state.showChangedOnly = false; + applyFilters(); + showToast(state.showLockedOnly ? "Showing locked settings only" : "Showing all matching settings"); + return; + case "reset-defaults": + await resetDefaults(); + return; + case "reload": + window.location.reload(); + return; + case "legend": + explainSources(); + return; + case "reset-help": + explainReset(); + return; + default: + showToast(`Unknown command: ${command}`, "warning"); + } + } + + rows.forEach((row) => { + row.input?.addEventListener(row.input.tagName === "SELECT" ? "change" : "input", updateView); + row.element.querySelector(".row-reset")?.addEventListener("click", () => { + if (row.locked || !row.input) return; + row.input.value = row.element.dataset.default || row.element.dataset.original || ""; + updateView(); + }); + row.element.querySelector(".row-info")?.addEventListener("click", () => showRowInfo(row)); + }); + + searchInput.addEventListener("input", applyFilters); + categoryButtons.forEach((button) => button.addEventListener("click", () => setCategory(button.dataset.category))); + saveButton.addEventListener("click", saveChanges); + exportButton.addEventListener("click", exportSettings); + importButton.addEventListener("click", () => importInput.click()); + resetButton.addEventListener("click", resetDefaults); + importInput.addEventListener("change", (event) => importSettingsFile(event.target.files?.[0])); + popupClose?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.()); + document.getElementById("modal-backdrop")?.addEventListener("click", () => window.WarpBoxUI?.closePopup?.()); + + document.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", async () => { + menuController.close(); + await runCommand(button.dataset.command); + }); + }); + + document.addEventListener("keydown", async (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + await saveChanges(); + } + if (event.key === "F5") { + event.preventDefault(); + window.location.reload(); + } + if (event.key === "Escape") { + menuController.close(); + window.WarpBoxUI?.closePopup?.(); + } + }); + + updateView(); +})(); diff --git a/templates/admin/boxes.html b/templates/admin/boxes.html new file mode 100644 index 0000000..86e02d8 --- /dev/null +++ b/templates/admin/boxes.html @@ -0,0 +1,245 @@ +{{ define "admin/boxes.html" }} + + + + + +Total boxes
+0
+All stored manifests and legacy boxes
+Ready
+0
+Complete and still available
+Uploading
+0
+Still waiting on files
+Expired / consumed
+0
+Needs cleanup or review
+| + | Box ID | +Status | +Files | +Size | +Retention | +Expires | +Flags | +Actions | +
|---|