2 Commits

Author SHA1 Message Date
e103829870 feat(models): add alert and box models
Adds comprehensive data structures for Alert and Box functionality across models.
2026-04-30 19:45:22 +03:00
2714907ff4 feat(config): add box owner policy settings
Adds configuration options and environment variables to manage
box owner policies, including settings for refresh counts and expiry.
2026-04-30 19:30:13 +03:00
35 changed files with 6044 additions and 38 deletions

View File

@@ -151,11 +151,6 @@ func buildAllEnvRows(includeHidden bool) []envRow {
} }
extra := buildExtraEnvRows(includeHidden) extra := buildExtraEnvRows(includeHidden)
if loadErr == nil {
for i := range extra {
extra[i].Default = extra[i].Default
}
}
rows = append(rows, extra...) rows = append(rows, extra...)
return rows return rows

View File

@@ -28,6 +28,12 @@ func TestDefaults(t *testing.T) {
if cfg.AdminPassword != "" { if cfg.AdminPassword != "" {
t.Fatal("expected default admin password to be empty") t.Fatal("expected default admin password to be empty")
} }
if !cfg.BoxOwnerEditEnabled || !cfg.BoxOwnerRefreshEnabled || !cfg.BoxOwnerPasswordEditEnabled {
t.Fatal("expected box owner policy defaults to be enabled")
}
if cfg.BoxOwnerMaxRefreshCount != 3 || cfg.BoxOwnerMaxRefreshAmountSeconds != 86400 || cfg.BoxOwnerMaxTotalExpirySeconds != 604800 {
t.Fatalf("unexpected box owner policy defaults: %#v", cfg)
}
} }
func TestEnvironmentOverrides(t *testing.T) { func TestEnvironmentOverrides(t *testing.T) {
@@ -39,6 +45,8 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000") t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root") t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true") t.Setenv("WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", "true")
t.Setenv("WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", "5")
t.Setenv("WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", "false")
cfg, err := Load() cfg, err := Load()
if err != nil { if err != nil {
@@ -63,6 +71,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if !cfg.OneTimeDownloadRetryOnFailure { if !cfg.OneTimeDownloadRetryOnFailure {
t.Fatal("expected one-time retry-on-failure env override to be applied") t.Fatal("expected one-time retry-on-failure env override to be applied")
} }
if cfg.BoxOwnerMaxRefreshCount != 5 || cfg.BoxOwnerPasswordEditEnabled {
t.Fatal("expected box owner policy env overrides to be applied")
}
if cfg.Source(SettingAPIEnabled) != SourceEnv { if cfg.Source(SettingAPIEnabled) != SourceEnv {
t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled)) t.Fatalf("expected API setting source to be env, got %s", cfg.Source(SettingAPIEnabled))
} }
@@ -148,6 +159,12 @@ func TestSettingsOverrideValidation(t *testing.T) {
if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil { if err := cfg.ApplyOverride(SettingGlobalMaxFileSizeBytes, "1"); err == nil {
t.Fatal("expected hard limit override to fail") t.Fatal("expected hard limit override to fail")
} }
if err := cfg.ApplyOverride(SettingBoxOwnerMaxRefreshCount, "2"); err != nil {
t.Fatalf("expected box owner policy override to pass: %v", err)
}
if cfg.BoxOwnerMaxRefreshCount != 2 {
t.Fatalf("expected box owner policy override to apply, got %d", cfg.BoxOwnerMaxRefreshCount)
}
} }
func clearConfigEnv(t *testing.T) { func clearConfigEnv(t *testing.T) {
@@ -181,6 +198,12 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS", "WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE", "WARPBOX_THUMBNAIL_BATCH_SIZE",
"WARPBOX_THUMBNAIL_INTERVAL_SECONDS", "WARPBOX_THUMBNAIL_INTERVAL_SECONDS",
"WARPBOX_BOX_OWNER_EDIT_ENABLED",
"WARPBOX_BOX_OWNER_REFRESH_ENABLED",
"WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT",
"WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS",
"WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS",
"WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED",
} { } {
t.Setenv(name, "") t.Setenv(name, "")
} }

View File

@@ -20,6 +20,12 @@ var Definitions = []SettingDefinition{
{Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000}, {Key: SettingBoxPollIntervalMS, EnvName: "WARPBOX_BOX_POLL_INTERVAL_MS", Label: "Box poll interval milliseconds", Type: SettingTypeInt, Editable: true, Minimum: 1000},
{Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingThumbnailBatchSize, EnvName: "WARPBOX_THUMBNAIL_BATCH_SIZE", Label: "Thumbnail batch size", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1}, {Key: SettingThumbnailIntervalSeconds, EnvName: "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", Label: "Thumbnail interval seconds", Type: SettingTypeInt, Editable: true, Minimum: 1},
{Key: SettingBoxOwnerEditEnabled, EnvName: "WARPBOX_BOX_OWNER_EDIT_ENABLED", Label: "Box owner edit enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingBoxOwnerRefreshEnabled, EnvName: "WARPBOX_BOX_OWNER_REFRESH_ENABLED", Label: "Box owner refresh enabled", Type: SettingTypeBool, Editable: true},
{Key: SettingBoxOwnerMaxRefreshCount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", Label: "Box owner max refresh count", Type: SettingTypeInt, Editable: true, Minimum: 0},
{Key: SettingBoxOwnerMaxRefreshAmount, EnvName: "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", Label: "Box owner max refresh amount seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingBoxOwnerMaxTotalExpiry, EnvName: "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", Label: "Box owner max total expiry seconds", Type: SettingTypeInt64, Editable: true, Minimum: 0},
{Key: SettingBoxOwnerPasswordEdit, EnvName: "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", Label: "Box owner password edit enabled", Type: SettingTypeBool, Editable: true},
} }
func (cfg *Config) SettingRows() []SettingRow { func (cfg *Config) SettingRows() []SettingRow {
@@ -38,6 +44,10 @@ func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key) return cfg.sourceFor(key)
} }
func (cfg *Config) SettingValue(key string) string {
return cfg.values[key]
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool { func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled { switch cfg.AdminEnabled {
case AdminEnabledFalse: case AdminEnabledFalse:

View File

@@ -27,6 +27,12 @@ func Load() (*Config, error) {
BoxPollIntervalMS: 5000, BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10, ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30, ThumbnailIntervalSeconds: 30,
BoxOwnerEditEnabled: true,
BoxOwnerRefreshEnabled: true,
BoxOwnerMaxRefreshCount: 3,
BoxOwnerMaxRefreshAmountSeconds: 24 * 60 * 60,
BoxOwnerMaxTotalExpirySeconds: 7 * 24 * 60 * 60,
BoxOwnerPasswordEditEnabled: true,
sources: make(map[string]Source), sources: make(map[string]Source),
values: make(map[string]string), values: make(map[string]string),
} }
@@ -73,6 +79,9 @@ func Load() (*Config, error) {
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure}, {SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled}, {SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled}, {SettingRenewOnDownloadEnabled, "WARPBOX_RENEW_ON_DOWNLOAD_ENABLED", &cfg.RenewOnDownloadEnabled},
{SettingBoxOwnerEditEnabled, "WARPBOX_BOX_OWNER_EDIT_ENABLED", &cfg.BoxOwnerEditEnabled},
{SettingBoxOwnerRefreshEnabled, "WARPBOX_BOX_OWNER_REFRESH_ENABLED", &cfg.BoxOwnerRefreshEnabled},
{SettingBoxOwnerPasswordEdit, "WARPBOX_BOX_OWNER_PASSWORD_EDIT_ENABLED", &cfg.BoxOwnerPasswordEditEnabled},
} }
for _, item := range envBools { for _, item := range envBools {
if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil { if err := cfg.applyBoolEnv(item.key, item.name, item.target); err != nil {
@@ -90,6 +99,8 @@ func Load() (*Config, error) {
{SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds}, {SettingMaxGuestExpirySecs, "WARPBOX_MAX_GUEST_EXPIRY_SECONDS", 0, &cfg.MaxGuestExpirySeconds},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds}, {SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds}, {SettingSessionTTLSeconds, "WARPBOX_SESSION_TTL_SECONDS", 60, &cfg.SessionTTLSeconds},
{SettingBoxOwnerMaxRefreshAmount, "WARPBOX_BOX_OWNER_MAX_REFRESH_AMOUNT_SECONDS", 0, &cfg.BoxOwnerMaxRefreshAmountSeconds},
{SettingBoxOwnerMaxTotalExpiry, "WARPBOX_BOX_OWNER_MAX_TOTAL_EXPIRY_SECONDS", 0, &cfg.BoxOwnerMaxTotalExpirySeconds},
} }
for _, item := range envInt64s { for _, item := range envInt64s {
if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil { if err := cfg.applyInt64Env(item.key, item.name, item.min, item.target); err != nil {
@@ -122,6 +133,7 @@ func Load() (*Config, error) {
{SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS}, {SettingBoxPollIntervalMS, "WARPBOX_BOX_POLL_INTERVAL_MS", 1000, &cfg.BoxPollIntervalMS},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize}, {SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds}, {SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
} }
for _, item := range envInts { for _, item := range envInts {
if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil { if err := cfg.applyIntEnv(item.key, item.name, item.min, item.target); err != nil {
@@ -171,6 +183,12 @@ func (cfg *Config) captureDefaults() {
cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault) cfg.setValue(SettingBoxPollIntervalMS, strconv.Itoa(cfg.BoxPollIntervalMS), SourceDefault)
cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault) cfg.setValue(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), SourceDefault)
cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault) cfg.setValue(SettingThumbnailIntervalSeconds, strconv.Itoa(cfg.ThumbnailIntervalSeconds), SourceDefault)
cfg.setValue(SettingBoxOwnerEditEnabled, formatBool(cfg.BoxOwnerEditEnabled), SourceDefault)
cfg.setValue(SettingBoxOwnerRefreshEnabled, formatBool(cfg.BoxOwnerRefreshEnabled), SourceDefault)
cfg.setValue(SettingBoxOwnerMaxRefreshCount, strconv.Itoa(cfg.BoxOwnerMaxRefreshCount), SourceDefault)
cfg.setValue(SettingBoxOwnerMaxRefreshAmount, strconv.FormatInt(cfg.BoxOwnerMaxRefreshAmountSeconds, 10), SourceDefault)
cfg.setValue(SettingBoxOwnerMaxTotalExpiry, strconv.FormatInt(cfg.BoxOwnerMaxTotalExpirySeconds, 10), SourceDefault)
cfg.setValue(SettingBoxOwnerPasswordEdit, formatBool(cfg.BoxOwnerPasswordEditEnabled), SourceDefault)
} }
func (cfg *Config) applyStringEnv(key string, name string, target *string) error { func (cfg *Config) applyStringEnv(key string, name string, target *string) error {

View File

@@ -36,6 +36,12 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size" SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds" SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
SettingDataDir = "data_dir" SettingDataDir = "data_dir"
SettingBoxOwnerEditEnabled = "box_owner_edit_enabled"
SettingBoxOwnerRefreshEnabled = "box_owner_refresh_enabled"
SettingBoxOwnerMaxRefreshCount = "box_owner_max_refresh_count"
SettingBoxOwnerMaxRefreshAmount = "box_owner_max_refresh_amount_seconds"
SettingBoxOwnerMaxTotalExpiry = "box_owner_max_total_expiry_seconds"
SettingBoxOwnerPasswordEdit = "box_owner_password_edit_enabled"
) )
type SettingType string type SettingType string
@@ -94,6 +100,12 @@ type Config struct {
BoxPollIntervalMS int BoxPollIntervalMS int
ThumbnailBatchSize int ThumbnailBatchSize int
ThumbnailIntervalSeconds int ThumbnailIntervalSeconds int
BoxOwnerEditEnabled bool
BoxOwnerRefreshEnabled bool
BoxOwnerMaxRefreshCount int
BoxOwnerMaxRefreshAmountSeconds int64
BoxOwnerMaxTotalExpirySeconds int64
BoxOwnerPasswordEditEnabled bool
sources map[string]Source sources map[string]Source
values map[string]string values map[string]string

View File

@@ -64,6 +64,12 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
cfg.RenewOnAccessEnabled = value cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled: case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value cfg.RenewOnDownloadEnabled = value
case SettingBoxOwnerEditEnabled:
cfg.BoxOwnerEditEnabled = value
case SettingBoxOwnerRefreshEnabled:
cfg.BoxOwnerRefreshEnabled = value
case SettingBoxOwnerPasswordEdit:
cfg.BoxOwnerPasswordEditEnabled = value
} }
cfg.setValue(key, formatBool(value), source) cfg.setValue(key, formatBool(value), source)
} }
@@ -82,6 +88,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds: case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value cfg.SessionTTLSeconds = value
case SettingBoxOwnerMaxRefreshAmount:
cfg.BoxOwnerMaxRefreshAmountSeconds = value
case SettingBoxOwnerMaxTotalExpiry:
cfg.BoxOwnerMaxTotalExpirySeconds = value
} }
cfg.setValue(key, strconv.FormatInt(value, 10), source) cfg.setValue(key, strconv.FormatInt(value, 10), source)
} }
@@ -94,6 +104,8 @@ func (cfg *Config) assignInt(key string, value int, source Source) {
cfg.ThumbnailBatchSize = value cfg.ThumbnailBatchSize = value
case SettingThumbnailIntervalSeconds: case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value cfg.ThumbnailIntervalSeconds = value
case SettingBoxOwnerMaxRefreshCount:
cfg.BoxOwnerMaxRefreshCount = value
} }
cfg.setValue(key, strconv.Itoa(value), source) cfg.setValue(key, strconv.Itoa(value), source)
} }

247
lib/metastore/alerts.go Normal file
View File

@@ -0,0 +1,247 @@
package metastore
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
"warpbox/lib/helpers"
)
const (
AlertSeverityLow = "low"
AlertSeverityMedium = "medium"
AlertSeverityHigh = "high"
AlertStatusOpen = "open"
AlertStatusAcknowledged = "acknowledged"
AlertStatusClosed = "closed"
)
func (store *Store) CreateAlert(input AlertInput) (Alert, error) {
alert, err := normalizeAlertInput(input)
if err != nil {
return Alert{}, err
}
id, err := helpers.RandomHexID(16)
if err != nil {
return Alert{}, err
}
now := time.Now().UTC()
alert.ID = id
alert.Status = AlertStatusOpen
alert.CreatedAt = now
alert.UpdatedAt = now
err = store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, alertKey(alert.ID), alert)
})
return alert, err
}
func (store *Store) ListAlerts(filters AlertFilters) ([]Alert, error) {
alerts := []Alert{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("alert/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var alert Alert
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &alert)
}); err != nil {
return err
}
if alertMatchesFilters(alert, filters) {
alerts = append(alerts, alert)
}
}
return nil
})
if err != nil {
return nil, err
}
sortAlerts(alerts, filters.Sort)
return alerts, nil
}
func (store *Store) GetAlert(id string) (Alert, bool, error) {
id = strings.TrimSpace(id)
if id == "" {
return Alert{}, false, nil
}
var alert Alert
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, alertKey(id), &alert)
})
if errors.Is(err, ErrNotFound) {
return Alert{}, false, nil
}
return alert, err == nil, err
}
func (store *Store) AcknowledgeAlert(id string) error {
return store.updateAlertStatus(id, AlertStatusAcknowledged)
}
func (store *Store) CloseAlert(id string) error {
return store.updateAlertStatus(id, AlertStatusClosed)
}
func (store *Store) updateAlertStatus(id string, status string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("%w: alert id cannot be empty", ErrInvalid)
}
status, err := normalizeAlertStatus(status)
if err != nil {
return err
}
now := time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
var alert Alert
if err := getJSON(txn, alertKey(id), &alert); err != nil {
return err
}
alert.Status = status
alert.UpdatedAt = now
switch status {
case AlertStatusAcknowledged:
alert.AcknowledgedAt = &now
case AlertStatusClosed:
alert.ClosedAt = &now
}
return putJSON(txn, alertKey(id), alert)
})
}
func normalizeAlertInput(input AlertInput) (Alert, error) {
title := strings.TrimSpace(input.Title)
description := strings.TrimSpace(input.Description)
code := strings.TrimSpace(input.Code)
trace := strings.TrimSpace(input.Trace)
severity, err := normalizeAlertSeverity(input.Severity)
if err != nil {
return Alert{}, err
}
if title == "" {
return Alert{}, fmt.Errorf("%w: alert title cannot be empty", ErrInvalid)
}
if code == "" {
return Alert{}, fmt.Errorf("%w: alert code cannot be empty", ErrInvalid)
}
if trace == "" {
return Alert{}, fmt.Errorf("%w: alert trace cannot be empty", ErrInvalid)
}
metadata := input.Metadata
if len(metadata) == 0 {
metadata = json.RawMessage(`{}`)
}
var object map[string]any
if err := json.Unmarshal(metadata, &object); err != nil {
return Alert{}, fmt.Errorf("%w: alert metadata must be a JSON object", ErrInvalid)
}
normalizedMetadata, err := json.Marshal(object)
if err != nil {
return Alert{}, err
}
return Alert{
Title: title,
Description: description,
Severity: severity,
Code: code,
Trace: trace,
Metadata: normalizedMetadata,
CreatedBy: strings.TrimSpace(input.CreatedBy),
}, nil
}
func normalizeAlertSeverity(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case AlertSeverityLow, AlertSeverityMedium, AlertSeverityHigh:
return strings.ToLower(strings.TrimSpace(value)), nil
default:
return "", fmt.Errorf("%w: invalid alert severity", ErrInvalid)
}
}
func normalizeAlertStatus(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case AlertStatusOpen, AlertStatusAcknowledged, AlertStatusClosed:
return strings.ToLower(strings.TrimSpace(value)), nil
default:
return "", fmt.Errorf("%w: invalid alert status", ErrInvalid)
}
}
func alertMatchesFilters(alert Alert, filters AlertFilters) bool {
query := strings.ToLower(strings.TrimSpace(filters.Query))
if query != "" {
haystack := strings.ToLower(strings.Join([]string{alert.Title, alert.Description, alert.Code, alert.Trace}, " "))
if !strings.Contains(haystack, query) {
return false
}
}
if severity := strings.ToLower(strings.TrimSpace(filters.Severity)); severity != "" && severity != "all" && alert.Severity != severity {
return false
}
if status := strings.ToLower(strings.TrimSpace(filters.Status)); status != "" && status != "all" && alert.Status != status {
return false
}
if group := strings.ToLower(strings.TrimSpace(filters.Group)); group != "" && group != "all" && alertGroup(alert.Trace) != group {
return false
}
return true
}
func sortAlerts(alerts []Alert, sortKey string) {
switch strings.ToLower(strings.TrimSpace(sortKey)) {
case "oldest":
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.Before(alerts[j].CreatedAt) })
case "severity":
sort.Slice(alerts, func(i int, j int) bool {
left := alertSeverityRank(alerts[i].Severity)
right := alertSeverityRank(alerts[j].Severity)
if left == right {
return alerts[i].CreatedAt.After(alerts[j].CreatedAt)
}
return left > right
})
default:
sort.Slice(alerts, func(i int, j int) bool { return alerts[i].CreatedAt.After(alerts[j].CreatedAt) })
}
}
func alertSeverityRank(severity string) int {
switch severity {
case AlertSeverityHigh:
return 3
case AlertSeverityMedium:
return 2
default:
return 1
}
}
func alertGroup(trace string) string {
trace = strings.TrimSpace(trace)
if trace == "" {
return "system"
}
before, _, found := strings.Cut(trace, ".")
if !found || before == "" {
return "system"
}
return strings.ToLower(before)
}
func alertKey(id string) []byte {
return []byte("alert/" + strings.TrimSpace(id))
}

View File

@@ -0,0 +1,89 @@
package metastore
import (
"encoding/json"
"testing"
)
func TestAlertCreateListFilterLifecycle(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
alert, err := store.CreateAlert(AlertInput{
Title: "Thumbnail failed",
Description: "Could not generate preview.",
Severity: AlertSeverityMedium,
Code: "601",
Trace: "thumbnail.generate.failed",
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
CreatedBy: "system",
})
if err != nil {
t.Fatalf("CreateAlert returned error: %v", err)
}
if alert.ID == "" || alert.Status != AlertStatusOpen {
t.Fatalf("unexpected alert: %#v", alert)
}
alerts, err := store.ListAlerts(AlertFilters{Severity: AlertSeverityMedium, Status: AlertStatusOpen})
if err != nil {
t.Fatalf("ListAlerts returned error: %v", err)
}
if len(alerts) != 1 || alerts[0].Trace != "thumbnail.generate.failed" {
t.Fatalf("unexpected filtered alerts: %#v", alerts)
}
if !json.Valid(alerts[0].Metadata) {
t.Fatalf("expected valid metadata JSON: %s", string(alerts[0].Metadata))
}
var metadata map[string]string
if err := json.Unmarshal(alerts[0].Metadata, &metadata); err != nil {
t.Fatalf("Unmarshal metadata returned error: %v", err)
}
if metadata["file"] != "photo.jpg" {
t.Fatalf("metadata did not survive round trip: %#v", metadata)
}
if err := store.AcknowledgeAlert(alert.ID); err != nil {
t.Fatalf("AcknowledgeAlert returned error: %v", err)
}
acknowledged, ok, err := store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if acknowledged.Status != AlertStatusAcknowledged || acknowledged.AcknowledgedAt == nil {
t.Fatalf("expected acknowledged alert, got %#v", acknowledged)
}
if err := store.CloseAlert(alert.ID); err != nil {
t.Fatalf("CloseAlert returned error: %v", err)
}
closed, ok, err := store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if closed.Status != AlertStatusClosed || closed.ClosedAt == nil {
t.Fatalf("expected closed alert, got %#v", closed)
}
}
func TestAlertRejectsInvalidMetadata(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
defer store.Close()
if _, err := store.CreateAlert(AlertInput{
Title: "Bad alert",
Severity: AlertSeverityLow,
Code: "999",
Trace: "test.bad",
Metadata: json.RawMessage(`[]`),
}); err == nil {
t.Fatal("expected non-object metadata to be rejected")
}
}

188
lib/metastore/boxes.go Normal file
View File

@@ -0,0 +1,188 @@
package metastore
import (
"encoding/json"
"errors"
"sort"
"strings"
"time"
"github.com/dgraph-io/badger/v4"
)
func (store *Store) UpsertBoxRecord(record BoxRecord) error {
record.ID = strings.TrimSpace(record.ID)
if record.ID == "" {
return errors.New("box id cannot be empty")
}
record.OwnerID = strings.TrimSpace(record.OwnerID)
record.OwnerUsername = strings.TrimSpace(record.OwnerUsername)
record.FileNames = uniqueStrings(record.FileNames)
record.UpdatedAt = time.Now().UTC()
return store.db.Update(func(txn *badger.Txn) error {
return putJSON(txn, boxRecordKey(record.ID), record)
})
}
func (store *Store) GetBoxRecord(id string) (BoxRecord, bool, error) {
var record BoxRecord
err := store.db.View(func(txn *badger.Txn) error {
return getJSON(txn, boxRecordKey(id), &record)
})
if errors.Is(err, ErrNotFound) {
return BoxRecord{}, false, nil
}
return record, err == nil, err
}
func (store *Store) DeleteBoxRecord(id string) error {
return store.db.Update(func(txn *badger.Txn) error {
err := txn.Delete(boxRecordKey(id))
if errors.Is(err, badger.ErrKeyNotFound) {
return nil
}
return err
})
}
func (store *Store) ListBoxRecords(filters BoxFilters, page BoxPageRequest) (BoxRecordPage, error) {
if page.Page < 1 {
page.Page = 1
}
switch page.PageSize {
case 25, 50, 100:
default:
page.PageSize = 25
}
rows := []BoxRecord{}
err := store.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte("box_record/")
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
var record BoxRecord
if err := it.Item().Value(func(data []byte) error {
return json.Unmarshal(data, &record)
}); err != nil {
return err
}
if boxRecordMatches(record, filters) {
rows = append(rows, record)
}
}
return nil
})
if err != nil {
return BoxRecordPage{}, err
}
sortBoxRecords(rows, filters.Sort)
total := len(rows)
start := (page.Page - 1) * page.PageSize
if start > total {
start = total
}
end := start + page.PageSize
if end > total {
end = total
}
totalPages := 1
if total > 0 {
totalPages = (total + page.PageSize - 1) / page.PageSize
}
return BoxRecordPage{
Rows: rows[start:end],
Page: page.Page,
PageSize: page.PageSize,
Total: total,
HasPrev: page.Page > 1,
HasNext: end < total,
PrevPage: maxInt(page.Page-1, 1),
NextPage: page.Page + 1,
TotalPages: totalPages,
}, nil
}
func boxRecordMatches(record BoxRecord, filters BoxFilters) bool {
query := strings.ToLower(strings.TrimSpace(filters.Query))
if query != "" {
haystack := strings.ToLower(record.ID + " " + record.OwnerUsername + " " + strings.Join(record.FileNames, " "))
if !strings.Contains(haystack, query) {
return false
}
}
owner := strings.ToLower(strings.TrimSpace(filters.Owner))
if owner != "" && owner != "all" && strings.ToLower(record.OwnerUsername) != owner && strings.ToLower(record.OwnerID) != owner {
return false
}
status := strings.ToLower(strings.TrimSpace(filters.Status))
if status != "" && status != "all" && boxRecordStatus(record) != status {
return false
}
switch strings.ToLower(strings.TrimSpace(filters.Flag)) {
case "", "all":
return true
case "password":
return record.PasswordProtected
case "one-time":
return record.OneTimeDownload
case "zip-disabled":
return record.DisableZip
case "expired":
return boxRecordExpired(record)
case "refreshable":
return !record.OneTimeDownload && !boxRecordExpired(record)
default:
return false
}
}
func sortBoxRecords(rows []BoxRecord, sortKey string) {
switch strings.ToLower(strings.TrimSpace(sortKey)) {
case "oldest":
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.Before(rows[j].CreatedAt) })
case "largest":
sort.Slice(rows, func(i int, j int) bool { return rows[i].TotalSize > rows[j].TotalSize })
case "expires":
sort.Slice(rows, func(i int, j int) bool { return rows[i].ExpiresAt.Before(rows[j].ExpiresAt) })
case "expired":
sort.Slice(rows, func(i int, j int) bool {
left := boxRecordExpired(rows[i])
right := boxRecordExpired(rows[j])
if left == right {
return rows[i].CreatedAt.After(rows[j].CreatedAt)
}
return left
})
default:
sort.Slice(rows, func(i int, j int) bool { return rows[i].CreatedAt.After(rows[j].CreatedAt) })
}
}
func boxRecordStatus(record BoxRecord) string {
if boxRecordExpired(record) {
return "expired"
}
if record.ExpiresAt.IsZero() {
return "pending"
}
return "active"
}
func boxRecordExpired(record BoxRecord) bool {
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
}
func boxRecordKey(id string) []byte {
return []byte("box_record/" + strings.TrimSpace(id))
}
func maxInt(a int, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -1,6 +1,9 @@
package metastore package metastore
import "time" import (
"encoding/json"
"time"
)
const AdminTagName = "admin" const AdminTagName = "admin"
@@ -74,3 +77,78 @@ type BootstrapResult struct {
AdminUser *User AdminUser *User
AdminLoginEnabled bool AdminLoginEnabled bool
} }
type Alert struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Status string `json:"status"`
Code string `json:"code"`
Trace string `json:"trace"`
Metadata json.RawMessage `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
CreatedBy string `json:"created_by"`
}
type AlertInput struct {
Title string
Description string
Severity string
Code string
Trace string
Metadata json.RawMessage
CreatedBy string
}
type AlertFilters struct {
Query string
Severity string
Status string
Group string
Sort string
}
type BoxRecord struct {
ID string `json:"id"`
OwnerID string `json:"owner_id,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
FileNames []string `json:"file_names,omitempty"`
FileCount int `json:"file_count"`
TotalSize int64 `json:"total_size"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
DisableZip bool `json:"disable_zip"`
RefreshCount int `json:"refresh_count"`
UpdatedAt time.Time `json:"updated_at"`
}
type BoxFilters struct {
Query string
Owner string
Status string
Flag string
Sort string
}
type BoxPageRequest struct {
Page int
PageSize int
}
type BoxRecordPage struct {
Rows []BoxRecord
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
TotalPages int
}

View File

@@ -42,6 +42,8 @@ type BoxFile struct {
type BoxManifest struct { type BoxManifest struct {
Files []BoxFile `json:"files"` Files []BoxFile `json:"files"`
OwnerID string `json:"owner_id,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
RetentionKey string `json:"retention_key"` RetentionKey string `json:"retention_key"`

View File

@@ -0,0 +1,386 @@
package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type AlertPageView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Filters AlertFiltersView
Stats AlertStatsView
Alerts []AlertRowView
SelectedAlert *AlertRowView
Groups []string
CanManageAlerts bool
}
type AlertFiltersView struct {
Query string
Severity string
Status string
Group string
Sort string
}
type AlertStatsView struct {
Open int
Acknowledged int
Closed int
High int
Medium int
Low int
}
type AlertRowView struct {
ID string
Title string
Description string
Severity string
Status string
Code string
Trace string
Group string
MetadataPretty string
CreatedAt string
UpdatedAt string
}
func (app *App) handleAccountAlerts(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.HTML(http.StatusOK, "account_alerts.html", page)
}
func (app *App) handleAccountAlertAcknowledge(ctx *gin.Context) {
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
return app.AcknowledgeAlert(ctx, actor, id)
})
}
func (app *App) handleAccountAlertClose(ctx *gin.Context) {
app.handleAccountAlertAction(ctx, func(actor metastore.User, id string) error {
return app.CloseAlert(ctx, actor, id)
})
}
func (app *App) handleAccountAlertBulkAcknowledge(ctx *gin.Context) {
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
return app.BulkAcknowledgeAlerts(ctx, actor, ids)
})
}
func (app *App) handleAccountAlertBulkClose(ctx *gin.Context) {
app.handleAccountAlertBulkAction(ctx, func(actor metastore.User, ids []string) error {
return app.BulkCloseAlerts(ctx, actor, ids)
})
}
func (app *App) handleAccountAlertsExport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
page, err := app.ListAlerts(ctx, actor, accountAlertFiltersFromRequest(ctx))
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.Header("Content-Disposition", `attachment; filename="warpbox-alerts.json"`)
ctx.JSON(http.StatusOK, gin.H{"alerts": page.Alerts, "filters": page.Filters, "stats": page.Stats})
}
func (app *App) handleAccountAlertAction(ctx *gin.Context, action func(metastore.User, string) error) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := action(actor, ctx.Param("id")); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
}
func (app *App) handleAccountAlertBulkAction(ctx *gin.Context, action func(metastore.User, []string) error) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := action(actor, ctx.PostFormArray("alert_ids")); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/alerts")
}
func (app *App) CreateAlert(ctx *gin.Context, actor metastore.User, input metastore.AlertInput) (metastore.Alert, error) {
if err := app.requireAlertManage(ctx); err != nil {
return metastore.Alert{}, err
}
if input.CreatedBy == "" {
input.CreatedBy = actor.Username
}
return app.store.CreateAlert(input)
}
func (app *App) ListAlerts(ctx *gin.Context, actor metastore.User, filters metastore.AlertFilters) (AlertPageView, error) {
if err := app.requireAlertView(ctx); err != nil {
return AlertPageView{}, err
}
alerts, err := app.store.ListAlerts(filters)
if err != nil {
return AlertPageView{}, err
}
rows := make([]AlertRowView, 0, len(alerts))
stats := AlertStatsView{}
groupSet := map[string]bool{}
for _, alert := range alerts {
row := alertRowView(alert)
rows = append(rows, row)
groupSet[row.Group] = true
switch alert.Status {
case metastore.AlertStatusAcknowledged:
stats.Acknowledged++
case metastore.AlertStatusClosed:
stats.Closed++
default:
stats.Open++
}
switch alert.Severity {
case metastore.AlertSeverityHigh:
stats.High++
case metastore.AlertSeverityMedium:
stats.Medium++
default:
stats.Low++
}
}
groups := make([]string, 0, len(groupSet))
for group := range groupSet {
groups = append(groups, group)
}
if len(groups) == 0 {
groups = []string{"system"}
}
nav := app.accountNavView(ctx, "alerts")
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
var selected *AlertRowView
if len(rows) > 0 {
selected = &rows[0]
}
return AlertPageView{
PageTitle: "WarpBox Alerts",
WindowTitle: "WarpBox Alerts",
WindowIcon: "!",
PageScripts: []string{"/static/js/account-alerts.js"},
AccountNav: nav,
CSRFToken: app.currentCSRFToken(ctx),
Filters: AlertFiltersView{Query: filters.Query, Severity: filters.Severity, Status: filters.Status, Group: filters.Group, Sort: filters.Sort},
Stats: stats,
Alerts: rows,
SelectedAlert: selected,
Groups: groups,
CanManageAlerts: currentAccountPermissions(ctx).AdminAccess,
}, nil
}
func (app *App) AcknowledgeAlert(ctx *gin.Context, actor metastore.User, id string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
return app.store.AcknowledgeAlert(id)
}
func (app *App) CloseAlert(ctx *gin.Context, actor metastore.User, id string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
return app.store.CloseAlert(id)
}
func (app *App) BulkAcknowledgeAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
for _, id := range uniqueNonEmpty(ids) {
if err := app.store.AcknowledgeAlert(id); err != nil {
return err
}
}
return nil
}
func (app *App) BulkCloseAlerts(ctx *gin.Context, actor metastore.User, ids []string) error {
if err := app.requireAlertManage(ctx); err != nil {
return err
}
for _, id := range uniqueNonEmpty(ids) {
if err := app.store.CloseAlert(id); err != nil {
return err
}
}
return nil
}
func (app *App) EmitSystemAlert(code string, severity string, title string, description string, trace string, metadata any) error {
raw, err := json.Marshal(metadata)
if err != nil {
log.Printf("alert metadata marshal failed: %v", err)
return err
}
_, err = app.store.CreateAlert(metastore.AlertInput{
Title: title,
Description: description,
Severity: severity,
Code: code,
Trace: trace,
Metadata: raw,
CreatedBy: "system",
})
if err != nil {
log.Printf("alert persistence failed: %v", err)
}
return err
}
func (app *App) requireAlertView(ctx *gin.Context) error {
if !currentAccountPermissions(ctx).AdminAccess {
return fmt.Errorf("permission denied")
}
return nil
}
func (app *App) requireAlertManage(ctx *gin.Context) error {
if !currentAccountPermissions(ctx).AdminAccess {
return fmt.Errorf("permission denied")
}
return nil
}
func accountAlertFiltersFromRequest(ctx *gin.Context) metastore.AlertFilters {
return metastore.AlertFilters{
Query: strings.TrimSpace(ctx.Query("q")),
Severity: emptyAsAll(ctx.Query("severity")),
Status: emptyAsAll(ctx.Query("status")),
Group: emptyAsAll(ctx.Query("group")),
Sort: emptyAsNewest(ctx.Query("sort")),
}
}
func emptyAsAll(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "all"
}
return value
}
func emptyAsNewest(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "newest"
}
return value
}
func alertRowView(alert metastore.Alert) AlertRowView {
return AlertRowView{
ID: alert.ID,
Title: alert.Title,
Description: alert.Description,
Severity: alert.Severity,
Status: alert.Status,
Code: alert.Code,
Trace: alert.Trace,
Group: alertGroupFromTrace(alert.Trace),
MetadataPretty: prettyAlertMetadata(alert.Metadata),
CreatedAt: formatAdminTime(alert.CreatedAt),
UpdatedAt: formatAdminTime(alert.UpdatedAt),
}
}
func prettyAlertMetadata(raw json.RawMessage) string {
if len(raw) == 0 {
return "{}"
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return string(raw)
}
pretty, err := json.MarshalIndent(value, "", " ")
if err != nil {
return string(raw)
}
return string(pretty)
}
func alertGroupFromTrace(trace string) string {
trace = strings.TrimSpace(trace)
if trace == "" {
return "system"
}
before, _, found := strings.Cut(trace, ".")
if !found || before == "" {
return "system"
}
return strings.ToLower(before)
}
func (app *App) openAlertSummary() (int, string) {
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen})
if err != nil {
return 0, "ok"
}
severity := "ok"
for _, alert := range alerts {
if alert.Severity == metastore.AlertSeverityHigh {
return len(alerts), "danger"
}
if alert.Severity == metastore.AlertSeverityMedium {
severity = "warning"
} else if severity == "ok" {
severity = "info"
}
}
return len(alerts), severity
}
func uniqueNonEmpty(values []string) []string {
seen := map[string]bool{}
out := []string{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}

View File

@@ -0,0 +1,155 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"warpbox/lib/metastore"
)
func TestAccountAlertsPageListsAndFiltersAlerts(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
createTestAlert(t, app, "701", metastore.AlertSeverityHigh, "storage.connector.health_failed")
request := httptest.NewRequest(http.MethodGet, "/account/alerts?severity=high", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected alerts page, got %d body=%s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, "storage.connector.health_failed") {
t.Fatal("expected high severity alert")
}
if strings.Contains(body, "thumbnail.generate.failed") {
t.Fatal("did not expect medium severity alert in high filter")
}
}
func TestAccountAlertAcknowledgeAndClose(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected acknowledge redirect, got %d", response.Code)
}
updated, ok, err := app.store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if updated.Status != metastore.AlertStatusAcknowledged {
t.Fatalf("expected acknowledged alert, got %s", updated.Status)
}
response = postAlertAction(router, session, "/account/alerts/"+alert.ID+"/close", nil)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected close redirect, got %d", response.Code)
}
updated, ok, err = app.store.GetAlert(alert.ID)
if err != nil || !ok {
t.Fatalf("GetAlert returned ok=%v err=%v", ok, err)
}
if updated.Status != metastore.AlertStatusClosed {
t.Fatalf("expected closed alert, got %s", updated.Status)
}
}
func TestAccountAlertManagePermissionDenied(t *testing.T) {
app, _ := setupAccountTestApp(t)
regular, err := app.store.CreateUserWithPassword("regular-alerts", "regular-alerts@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, regular)
alert := createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
response := postAlertAction(router, session, "/account/alerts/"+alert.ID+"/acknowledge", nil)
if response.Code != http.StatusForbidden {
t.Fatalf("expected permission denied, got %d", response.Code)
}
}
func TestDashboardUsesRealAlertCount(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
request := httptest.NewRequest(http.MethodGet, "/account", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected dashboard, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "1 alerts") {
t.Fatal("expected dashboard alert chip/count")
}
if !strings.Contains(response.Body.String(), "Thumbnail alert") {
t.Fatal("expected dashboard alert preview")
}
}
func TestAccountAlertsExportJSON(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createTestAlert(t, app, "601", metastore.AlertSeverityMedium, "thumbnail.generate.failed")
request := httptest.NewRequest(http.MethodGet, "/account/alerts/export.json", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected export success, got %d", response.Code)
}
var payload map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if _, ok := payload["alerts"]; !ok {
t.Fatal("expected alerts export shape")
}
}
func createTestAlert(t *testing.T, app *App, code string, severity string, trace string) metastore.Alert {
t.Helper()
alert, err := app.store.CreateAlert(metastore.AlertInput{
Title: "Thumbnail alert",
Description: "Alert test description.",
Severity: severity,
Code: code,
Trace: trace,
Metadata: json.RawMessage(`{"box":"box-1","file":"photo.jpg"}`),
CreatedBy: "system",
})
if err != nil {
t.Fatalf("CreateAlert returned error: %v", err)
}
return alert
}
func postAlertAction(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
if values == nil {
values = url.Values{}
}
values.Set("csrf_token", session.CSRFToken)
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

174
lib/server/account_auth.go Normal file
View File

@@ -0,0 +1,174 @@
package server
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
const accountSessionCookie = "warpbox_account_session"
func (app *App) registerAccountRoutes(router *gin.Engine) {
account := router.Group("/account")
account.Use(noStoreAdminHeaders)
account.GET("/login", app.handleAccountLogin)
account.POST("/login", app.handleAccountLoginPost)
protected := account.Group("")
protected.Use(app.requireAccountSession)
protected.GET("", app.handleAccountDashboard)
protected.GET("/", app.handleAccountDashboard)
protected.POST("/logout", app.handleAccountLogout)
protected.GET("/settings", app.handleAccountSettings)
protected.POST("/settings", app.handleAccountSettingsPost)
protected.POST("/settings/reset", app.handleAccountSettingsReset)
protected.GET("/settings/export.json", app.handleAccountSettingsExport)
protected.POST("/settings/import.json", app.handleAccountSettingsImport)
protected.GET("/alerts", app.handleAccountAlerts)
protected.GET("/alerts/export.json", app.handleAccountAlertsExport)
protected.POST("/alerts/bulk/acknowledge", app.handleAccountAlertBulkAcknowledge)
protected.POST("/alerts/bulk/close", app.handleAccountAlertBulkClose)
protected.POST("/alerts/:id/acknowledge", app.handleAccountAlertAcknowledge)
protected.POST("/alerts/:id/close", app.handleAccountAlertClose)
protected.GET("/boxes", app.handleAccountBoxes)
protected.GET("/boxes/export.csv", app.handleAccountBoxesExport)
protected.POST("/boxes/bulk/expire", app.handleAccountBoxesBulkExpire)
protected.POST("/boxes/bulk/delete", app.handleAccountBoxesBulkDelete)
protected.POST("/boxes/bulk/bump-expiry", app.handleAccountBoxesBulkBumpExpiry)
protected.POST("/boxes/delete-largest", app.handleAccountBoxesDeleteLargest)
}
func (app *App) handleAccountLogin(ctx *gin.Context) {
if app.isAccountSessionValid(ctx) {
ctx.Redirect(http.StatusSeeOther, "/account")
return
}
app.renderAccountLogin(ctx, "")
}
func (app *App) handleAccountLoginPost(ctx *gin.Context) {
if !app.adminLoginEnabled {
app.renderAccountLogin(ctx, "Account login is disabled.")
return
}
username := strings.TrimSpace(ctx.PostForm("username"))
password := ctx.PostForm("password")
user, ok, err := app.store.GetUserByUsername(username)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load user")
return
}
if !ok || user.Disabled || !metastore.VerifyPassword(user.PasswordHash, password) {
app.renderAccountLogin(ctx, "The username or password was not accepted.")
return
}
if _, err := app.permissionsForUser(user); err != nil {
ctx.String(http.StatusInternalServerError, "Could not load permissions")
return
}
session, err := app.store.CreateSession(user.ID, time.Duration(app.config.SessionTTLSeconds)*time.Second)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not create session")
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(accountSessionCookie, session.Token, int(app.config.SessionTTLSeconds), "/account", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/account")
}
func (app *App) handleAccountLogout(ctx *gin.Context) {
if token, err := ctx.Cookie(accountSessionCookie); err == nil {
_ = app.store.DeleteSession(token)
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(accountSessionCookie, "", -1, "/account", "", app.config.AdminCookieSecure, true)
ctx.Redirect(http.StatusSeeOther, "/account/login")
}
func (app *App) requireAccountSession(ctx *gin.Context) {
token, err := ctx.Cookie(accountSessionCookie)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
if !validAdminCSRF(ctx, session) {
ctx.String(http.StatusForbidden, "Permission denied")
ctx.Abort()
return
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
perms, err := app.permissionsForUser(user)
if err != nil {
ctx.Redirect(http.StatusSeeOther, "/account/login")
ctx.Abort()
return
}
ctx.Set("accountUser", user)
ctx.Set("adminUser", user)
ctx.Set("accountPerms", perms)
ctx.Set("adminPerms", perms)
ctx.Set("accountSession", session)
ctx.Set("accountCSRFToken", session.CSRFToken)
ctx.Set("adminCSRFToken", session.CSRFToken)
ctx.Next()
}
func (app *App) isAccountSessionValid(ctx *gin.Context) bool {
token, err := ctx.Cookie(accountSessionCookie)
if err != nil {
return false
}
session, ok, err := app.store.GetSession(token)
if err != nil || !ok {
return false
}
user, ok, err := app.store.GetUser(session.UserID)
if err != nil || !ok || user.Disabled {
return false
}
_, err = app.permissionsForUser(user)
return err == nil
}
func (app *App) renderAccountLogin(ctx *gin.Context, errorMessage string) {
ctx.HTML(http.StatusOK, "account_login.html", gin.H{
"PageTitle": "WarpBox Account Login",
"AdminLoginEnabled": app.adminLoginEnabled,
"AccountLoginEnabled": app.adminLoginEnabled,
"Error": errorMessage,
})
}
func currentAccountUser(ctx *gin.Context) (metastore.User, bool) {
if current, ok := ctx.Get("accountUser"); ok {
if user, ok := current.(metastore.User); ok {
return user, true
}
}
if current, ok := ctx.Get("adminUser"); ok {
if user, ok := current.(metastore.User); ok {
return user, true
}
}
return metastore.User{}, false
}

454
lib/server/account_boxes.go Normal file
View File

@@ -0,0 +1,454 @@
package server
import (
"bytes"
"encoding/csv"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
type BoxIndexView struct {
PageTitle string
WindowTitle string
WindowIcon string
AccountNav AccountNavView
CSRFToken string
Filters BoxFiltersView
Rows []BoxRowView
Stats BoxIndexStats
Page int
PageSize int
Total int
TotalPages int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
CanManage bool
PolicySummary string
Error string
}
type BoxFiltersView struct {
Query string
Owner string
Status string
Flag string
Sort string
PageSize int
}
type BoxIndexStats struct {
Visible int
Total int
Expired int
Storage string
}
type BoxRowView struct {
ID string
Owner string
Status string
FileCount int
Size string
CreatedAt string
ExpiresAt string
Flags string
Policy string
CanManage bool
ManageURL string
OpenURL string
}
func (app *App) handleAccountBoxes(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
view, err := app.ListBoxes(ctx, actor, boxFiltersFromRequest(ctx), boxPageFromRequest(ctx))
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.HTML(http.StatusOK, "account_boxes.html", view)
}
func (app *App) handleAccountBoxesBulkExpire(ctx *gin.Context) {
app.handleAccountBoxesBulkAction(ctx, app.ExpireBoxes)
}
func (app *App) handleAccountBoxesBulkDelete(ctx *gin.Context) {
app.handleAccountBoxesBulkAction(ctx, app.DeleteBoxes)
}
func (app *App) handleAccountBoxesBulkBumpExpiry(ctx *gin.Context) {
app.handleAccountBoxesBulkAction(ctx, func(ctx *gin.Context, actor metastore.User, ids []string) error {
seconds := parsePositiveInt64Default(ctx.PostForm("bump_seconds"), app.config.BoxOwnerMaxRefreshAmountSeconds)
return app.BumpBoxExpiries(ctx, actor, ids, seconds)
})
}
func (app *App) handleAccountBoxesDeleteLargest(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
filters := boxFiltersFromRequest(ctx)
filters.Sort = "largest"
page := metastore.BoxPageRequest{Page: 1, PageSize: 25}
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
if err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ids := make([]string, 0, 10)
for _, row := range boxPage.Rows {
if len(ids) == 10 {
break
}
ids = append(ids, row.ID)
}
if err := app.DeleteBoxes(ctx, actor, ids); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
}
func (app *App) handleAccountBoxesExport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
page, err := app.visibleBoxRecords(ctx, actor, boxFiltersFromRequest(ctx), metastore.BoxPageRequest{Page: 1, PageSize: 100})
if err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
var buffer bytes.Buffer
writer := csv.NewWriter(&buffer)
_ = writer.Write([]string{"id", "owner", "status", "file_count", "total_size", "created_at", "expires_at", "flags"})
for _, record := range page.Rows {
_ = writer.Write([]string{record.ID, record.OwnerUsername, boxStatus(record), strconv.Itoa(record.FileCount), strconv.FormatInt(record.TotalSize, 10), record.CreatedAt.Format(time.RFC3339), record.ExpiresAt.Format(time.RFC3339), boxFlags(record)})
}
writer.Flush()
ctx.Header("Content-Disposition", `attachment; filename="warpbox-boxes.csv"`)
ctx.Data(http.StatusOK, "text/csv; charset=utf-8", buffer.Bytes())
}
func (app *App) handleAccountBoxesBulkAction(ctx *gin.Context, action func(*gin.Context, metastore.User, []string) error) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := action(ctx, actor, ctx.PostFormArray("box_ids")); err != nil {
ctx.String(http.StatusForbidden, err.Error())
return
}
ctx.Redirect(http.StatusSeeOther, "/account/boxes")
}
func (app *App) ListBoxes(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (BoxIndexView, error) {
boxPage, err := app.visibleBoxRecords(ctx, actor, filters, page)
if err != nil {
return BoxIndexView{}, err
}
rows := make([]BoxRowView, 0, len(boxPage.Rows))
stats := BoxIndexStats{Visible: len(boxPage.Rows), Total: boxPage.Total}
totalSize := int64(0)
for _, record := range boxPage.Rows {
totalSize += record.TotalSize
if boxExpired(record) {
stats.Expired++
}
rows = append(rows, app.boxRowView(ctx, actor, record))
}
stats.Storage = helpers.FormatBytes(totalSize)
nav := app.accountNavView(ctx, "boxes")
nav.AlertCount, nav.AlertSeverity = app.openAlertSummary()
return BoxIndexView{
PageTitle: "WarpBox Boxes",
WindowTitle: "WarpBox Boxes",
WindowIcon: "B",
AccountNav: nav,
CSRFToken: app.currentCSRFToken(ctx),
Filters: BoxFiltersView{Query: filters.Query, Owner: filters.Owner, Status: filters.Status, Flag: filters.Flag, Sort: filters.Sort, PageSize: boxPage.PageSize},
Rows: rows,
Stats: stats,
Page: boxPage.Page,
PageSize: boxPage.PageSize,
Total: boxPage.Total,
TotalPages: boxPage.TotalPages,
HasPrev: boxPage.HasPrev,
HasNext: boxPage.HasNext,
PrevURL: boxPageURL(ctx, boxPage.PrevPage),
NextURL: boxPageURL(ctx, boxPage.NextPage),
CanManage: currentAccountPermissions(ctx).AdminBoxesView,
PolicySummary: app.boxPolicySummary(),
}, nil
}
func (app *App) ExpireBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
records, err := app.authorizedBoxRecords(ctx, actor, ids)
if err != nil {
return err
}
now := time.Now().UTC().Add(-time.Second)
for _, record := range records {
manifest, err := boxstore.ReadManifest(record.ID)
if err == nil {
manifest.ExpiresAt = now
_ = boxstore.WriteManifest(record.ID, manifest)
}
record.ExpiresAt = now
if err := app.store.UpsertBoxRecord(record); err != nil {
return err
}
}
return nil
}
func (app *App) DeleteBoxes(ctx *gin.Context, actor metastore.User, ids []string) error {
records, err := app.authorizedBoxRecords(ctx, actor, ids)
if err != nil {
return err
}
for _, record := range records {
if err := boxstore.DeleteBox(record.ID); err != nil {
return err
}
if err := app.store.DeleteBoxRecord(record.ID); err != nil {
return err
}
}
return nil
}
func (app *App) BumpBoxExpiries(ctx *gin.Context, actor metastore.User, ids []string, seconds int64) error {
if seconds <= 0 {
return fmt.Errorf("bump expiry requires a positive duration")
}
if !app.config.BoxOwnerRefreshEnabled {
return fmt.Errorf("box owner refresh policy is disabled")
}
if app.config.BoxOwnerMaxRefreshAmountSeconds > 0 && seconds > app.config.BoxOwnerMaxRefreshAmountSeconds {
return fmt.Errorf("bump expiry exceeds maximum refresh amount")
}
records, err := app.authorizedBoxRecords(ctx, actor, ids)
if err != nil {
return err
}
for _, record := range records {
if record.OneTimeDownload {
return fmt.Errorf("one-time boxes cannot be refreshed")
}
if app.config.BoxOwnerMaxRefreshCount > 0 && record.RefreshCount >= app.config.BoxOwnerMaxRefreshCount {
return fmt.Errorf("box refresh count limit reached")
}
base := record.ExpiresAt
if base.IsZero() || time.Now().UTC().After(base) {
base = time.Now().UTC()
}
newExpiry := base.Add(time.Duration(seconds) * time.Second)
if app.config.BoxOwnerMaxTotalExpirySeconds > 0 && !record.CreatedAt.IsZero() && newExpiry.After(record.CreatedAt.Add(time.Duration(app.config.BoxOwnerMaxTotalExpirySeconds)*time.Second)) {
return fmt.Errorf("bump expiry exceeds maximum total expiry")
}
manifest, err := boxstore.ReadManifest(record.ID)
if err == nil {
manifest.ExpiresAt = newExpiry
_ = boxstore.WriteManifest(record.ID, manifest)
}
record.ExpiresAt = newExpiry
record.RefreshCount++
if err := app.store.UpsertBoxRecord(record); err != nil {
return err
}
}
return nil
}
func (app *App) visibleBoxRecords(ctx *gin.Context, actor metastore.User, filters metastore.BoxFilters, page metastore.BoxPageRequest) (metastore.BoxRecordPage, error) {
perms := currentAccountPermissions(ctx)
if !perms.AdminBoxesView {
filters.Owner = actor.ID
}
return app.store.ListBoxRecords(filters, page)
}
func (app *App) authorizedBoxRecords(ctx *gin.Context, actor metastore.User, ids []string) ([]metastore.BoxRecord, error) {
ids = uniqueNonEmpty(ids)
if len(ids) == 0 {
return nil, fmt.Errorf("no boxes selected")
}
perms := currentAccountPermissions(ctx)
records := make([]metastore.BoxRecord, 0, len(ids))
for _, id := range ids {
record, ok, err := app.store.GetBoxRecord(id)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("box %s not found", id)
}
if !perms.AdminBoxesView && record.OwnerID != actor.ID {
return nil, fmt.Errorf("permission denied")
}
if !perms.AdminBoxesView && !app.config.BoxOwnerEditEnabled {
return nil, fmt.Errorf("box owner edit policy is disabled")
}
records = append(records, record)
}
return records, nil
}
func (app *App) boxRowView(ctx *gin.Context, actor metastore.User, record metastore.BoxRecord) BoxRowView {
owner := record.OwnerUsername
if owner == "" {
owner = "guest"
}
return BoxRowView{
ID: record.ID,
Owner: owner,
Status: boxStatus(record),
FileCount: record.FileCount,
Size: helpers.FormatBytes(record.TotalSize),
CreatedAt: formatAdminTime(record.CreatedAt),
ExpiresAt: formatAdminTime(record.ExpiresAt),
Flags: boxFlags(record),
Policy: app.boxRecordPolicy(record),
CanManage: currentAccountPermissions(ctx).AdminBoxesView || record.OwnerID == actor.ID,
ManageURL: "/account/boxes/" + record.ID,
OpenURL: "/box/" + record.ID,
}
}
func (app *App) indexBoxFromManifest(boxID string) {
manifest, err := boxstore.ReadManifest(boxID)
if err != nil {
return
}
_ = app.store.UpsertBoxRecord(boxRecordFromManifest(boxID, manifest))
}
func boxRecordFromManifest(boxID string, manifest models.BoxManifest) metastore.BoxRecord {
total := int64(0)
names := make([]string, 0, len(manifest.Files))
for _, file := range manifest.Files {
total += file.Size
names = append(names, file.Name)
}
return metastore.BoxRecord{
ID: boxID,
OwnerID: manifest.OwnerID,
OwnerUsername: manifest.OwnerUsername,
FileNames: names,
FileCount: len(manifest.Files),
TotalSize: total,
CreatedAt: manifest.CreatedAt,
ExpiresAt: manifest.ExpiresAt,
PasswordProtected: boxstore.IsPasswordProtected(manifest),
OneTimeDownload: manifest.OneTimeDownload,
DisableZip: manifest.DisableZip,
}
}
func boxFiltersFromRequest(ctx *gin.Context) metastore.BoxFilters {
return metastore.BoxFilters{
Query: strings.TrimSpace(ctx.Query("q")),
Owner: emptyAsAll(ctx.Query("owner")),
Status: emptyAsAll(ctx.Query("status")),
Flag: emptyAsAll(ctx.Query("flag")),
Sort: emptyAsNewest(ctx.Query("sort")),
}
}
func boxPageFromRequest(ctx *gin.Context) metastore.BoxPageRequest {
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "25"))
return metastore.BoxPageRequest{Page: page, PageSize: pageSize}
}
func boxStatus(record metastore.BoxRecord) string {
if boxExpired(record) {
return "expired"
}
if record.ExpiresAt.IsZero() {
return "pending"
}
return "active"
}
func boxExpired(record metastore.BoxRecord) bool {
return !record.ExpiresAt.IsZero() && time.Now().UTC().After(record.ExpiresAt)
}
func boxFlags(record metastore.BoxRecord) string {
flags := []string{}
if record.PasswordProtected {
flags = append(flags, "password")
}
if record.OneTimeDownload {
flags = append(flags, "one-time")
}
if record.DisableZip {
flags = append(flags, "zip disabled")
}
if boxExpired(record) {
flags = append(flags, "expired")
}
if len(flags) == 0 {
return "normal"
}
return strings.Join(flags, ", ")
}
func (app *App) boxRecordPolicy(record metastore.BoxRecord) string {
if record.OneTimeDownload {
return "one-time: no refresh"
}
if !app.config.BoxOwnerEditEnabled {
return "owner edits disabled"
}
if !app.config.BoxOwnerRefreshEnabled {
return "editable, no refresh"
}
return fmt.Sprintf("editable, refresh %d/%d", record.RefreshCount, app.config.BoxOwnerMaxRefreshCount)
}
func (app *App) boxPolicySummary() string {
if !app.config.BoxOwnerEditEnabled {
return "Owners cannot edit boxes by default."
}
if !app.config.BoxOwnerRefreshEnabled {
return "Owners can edit boxes but cannot refresh expiry."
}
return fmt.Sprintf("Owners can edit and refresh up to %d times by %s.", app.config.BoxOwnerMaxRefreshCount, formatDurationForSettings(app.config.BoxOwnerMaxRefreshAmountSeconds))
}
func boxPageURL(ctx *gin.Context, page int) string {
query := ctx.Request.URL.Query()
query.Set("page", strconv.Itoa(page))
return "/account/boxes?" + query.Encode()
}
func parsePositiveInt64Default(raw string, fallback int64) int64 {
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || value <= 0 {
return fallback
}
return value
}

View File

@@ -0,0 +1,220 @@
package server
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"warpbox/lib/boxstore"
"warpbox/lib/metastore"
"warpbox/lib/models"
)
func TestAccountBoxesAdminListsBoxes(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createIndexedBox(t, app, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", "", 10, false)
response := getAccountBoxes(router, session, "/account/boxes")
if response.Code != http.StatusOK {
t.Fatalf("expected boxes page, got %d body=%s", response.Code, response.Body.String())
}
if !strings.Contains(response.Body.String(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") {
t.Fatal("expected indexed box in admin list")
}
}
func TestAccountBoxesRegularUserSeesOnlyOwnBoxes(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("box-user", "box-user@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createIndexedBox(t, app, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", user.ID, user.Username, 10, false)
createIndexedBox(t, app, "cccccccccccccccccccccccccccccccc", "other", "other", 20, false)
response := getAccountBoxes(router, session, "/account/boxes")
if response.Code != http.StatusOK {
t.Fatalf("expected boxes page, got %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") {
t.Fatal("expected own box")
}
if strings.Contains(body, "cccccccccccccccccccccccccccccccc") {
t.Fatal("did not expect other user's box")
}
}
func TestAccountBoxesFiltersSortAndPagination(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
createIndexedBox(t, app, "11111111111111111111111111111111", "", "", 10, false)
createIndexedBox(t, app, "22222222222222222222222222222222", "", "", 1000, true)
createIndexedBox(t, app, "33333333333333333333333333333333", "", "", 500, false)
response := getAccountBoxes(router, session, "/account/boxes?flag=password&sort=largest&page_size=25")
if response.Code != http.StatusOK {
t.Fatalf("expected boxes page, got %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "22222222222222222222222222222222") {
t.Fatal("expected password filtered box")
}
if strings.Contains(body, "11111111111111111111111111111111") {
t.Fatal("did not expect unfiltered box")
}
page, err := app.store.ListBoxRecords(metastore.BoxFilters{Sort: "largest"}, metastore.BoxPageRequest{Page: 1, PageSize: 25})
if err != nil {
t.Fatalf("ListBoxRecords returned error: %v", err)
}
if len(page.Rows) != 3 || page.Rows[0].ID != "22222222222222222222222222222222" {
t.Fatalf("expected largest sort first, got %#v", page.Rows)
}
}
func TestAccountBoxesBulkExpireAndDelete(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "dddddddddddddddddddddddddddddddd"
createIndexedBox(t, app, id, "", "", 10, false)
values := url.Values{"box_ids": []string{id}}
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/expire", values)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected expire redirect, got %d", response.Code)
}
record, ok, err := app.store.GetBoxRecord(id)
if err != nil || !ok {
t.Fatalf("GetBoxRecord returned ok=%v err=%v", ok, err)
}
if record.ExpiresAt.After(time.Now().UTC()) {
t.Fatal("expected box to be expired")
}
response = postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", values)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected delete redirect, got %d", response.Code)
}
if _, ok, err := app.store.GetBoxRecord(id); err != nil || ok {
t.Fatalf("expected deleted record, ok=%v err=%v", ok, err)
}
if _, err := os.Stat(boxstore.BoxPath(id)); !os.IsNotExist(err) {
t.Fatalf("expected box directory deleted, stat err=%v", err)
}
}
func TestAccountBoxesBulkDeletePermissionDenied(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("box-limited", "box-limited@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
createIndexedBox(t, app, id, "other", "other", 10, false)
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/delete", url.Values{"box_ids": []string{id}})
if response.Code != http.StatusForbidden {
t.Fatalf("expected permission denied, got %d", response.Code)
}
}
func TestAccountBoxesBumpExpiryPolicyRejection(t *testing.T) {
app, user := setupAccountTestApp(t)
app.config.BoxOwnerRefreshEnabled = false
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
id := "ffffffffffffffffffffffffffffffff"
createIndexedBox(t, app, id, "", "", 10, false)
response := postAccountBoxesForm(router, session, "/account/boxes/bulk/bump-expiry", url.Values{"box_ids": []string{id}, "bump_seconds": []string{"60"}})
if response.Code != http.StatusForbidden {
t.Fatalf("expected policy rejection, got %d", response.Code)
}
}
func TestAccountBoxesDeleteLargest(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
small := "12345123451234512345123451234512"
large := "99999999999999999999999999999999"
createIndexedBox(t, app, small, "", "", 10, false)
createIndexedBox(t, app, large, "", "", 1000, false)
response := postAccountBoxesForm(router, session, "/account/boxes/delete-largest", nil)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected delete-largest redirect, got %d", response.Code)
}
if _, ok, err := app.store.GetBoxRecord(large); err != nil || ok {
t.Fatalf("expected largest deleted, ok=%v err=%v", ok, err)
}
}
func createIndexedBox(t *testing.T, app *App, id string, ownerID string, ownerUsername string, size int64, password bool) {
t.Helper()
if err := os.MkdirAll(boxstore.BoxPath(id), 0755); err != nil {
t.Fatalf("MkdirAll returned error: %v", err)
}
filename := "file-" + id[:4] + ".txt"
if err := os.WriteFile(filepath.Join(boxstore.BoxPath(id), filename), []byte(strings.Repeat("x", int(size))), 0644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
manifest := models.BoxManifest{
OwnerID: ownerID,
OwnerUsername: ownerUsername,
Files: []models.BoxFile{{
ID: "abcdabcdabcdabcd",
Name: filename,
Size: size,
Status: models.FileStatusReady,
}},
CreatedAt: time.Now().UTC().Add(-time.Duration(size) * time.Second),
ExpiresAt: time.Now().UTC().Add(time.Hour),
RetentionSecs: 3600,
}
if password {
manifest.PasswordHash = "hash"
manifest.AuthToken = "token"
}
if err := boxstore.WriteManifest(id, manifest); err != nil {
t.Fatalf("WriteManifest returned error: %v", err)
}
if err := app.store.UpsertBoxRecord(boxRecordFromManifest(id, manifest)); err != nil {
t.Fatalf("UpsertBoxRecord returned error: %v", err)
}
}
func getAccountBoxes(router http.Handler, session metastore.Session, path string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func postAccountBoxesForm(router http.Handler, session metastore.Session, path string, values url.Values) *httptest.ResponseRecorder {
if values == nil {
values = url.Values{}
}
values.Set("csrf_token", session.CSRFToken)
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

61
lib/server/account_nav.go Normal file
View File

@@ -0,0 +1,61 @@
package server
import (
"strings"
"github.com/gin-gonic/gin"
"warpbox/lib/metastore"
)
type AccountNavView struct {
Username string
IsAdmin bool
ActiveSection string
AlertCount int
AlertSeverity string
CanViewBoxes bool
CanViewAlerts bool
CanViewUsers bool
CanViewAPIKeys bool
CanViewSettings bool
}
func (app *App) accountNavView(ctx *gin.Context, activeSection string) AccountNavView {
perms := currentAccountPermissions(ctx)
isAdmin := perms.AdminAccess
return AccountNavView{
Username: app.currentAdminUsername(ctx),
IsAdmin: isAdmin,
ActiveSection: activeSection,
AlertSeverity: "ok",
CanViewBoxes: true,
CanViewAlerts: true,
CanViewUsers: perms.AdminUsersManage,
CanViewAPIKeys: true,
CanViewSettings: perms.AdminSettingsManage,
}
}
func currentAccountPermissions(ctx *gin.Context) metastore.EffectivePermissions {
value, ok := ctx.Get("adminPerms")
if !ok {
return metastore.EffectivePermissions{}
}
perms, ok := value.(metastore.EffectivePermissions)
if !ok {
return metastore.EffectivePermissions{}
}
return perms
}
func normalizeAlertSeverity(severity string) string {
normalized := strings.ToLower(strings.TrimSpace(severity))
switch normalized {
case "danger", "warning", "info", "ok":
return normalized
default:
return "ok"
}
}

253
lib/server/account_pages.go Normal file
View File

@@ -0,0 +1,253 @@
package server
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/helpers"
"warpbox/lib/metastore"
)
type AccountDashboardView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Stats AccountDashboardStats
Statuses []accountStatusRow
Alerts []accountAlertPreviewRow
RecentBoxes []accountDashboardBoxRow
RecentActivity []accountActivityRow
ShowUsersStat bool
CanManageBoxes bool
CanManageUsers bool
CanViewSettings bool
HasAlertsPreview bool
}
type AccountDashboardStats struct {
ActiveBoxes int
StorageUsedLabel string
AlertCount int
TotalUsers int
ActiveUsers int
DisabledUsers int
}
type accountStatusRow struct {
Label string
Value string
Severity string
}
type accountAlertPreviewRow struct {
Severity string
Title string
Detail string
}
type accountDashboardBoxRow struct {
ID string
FileCount int
TotalSizeLabel string
CreatedAt string
ExpiresAt string
Flags string
CanManage bool
}
type accountActivityRow struct {
Time string
Title string
Meta string
}
func (app *App) handleAccountDashboard(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
view, err := app.GetAccountDashboard(ctx, actor)
if err != nil {
ctx.String(http.StatusInternalServerError, "Could not load account dashboard")
return
}
ctx.HTML(http.StatusOK, "account_dashboard.html", view)
}
func (app *App) GetAccountDashboard(ctx *gin.Context, actor metastore.User) (AccountDashboardView, error) {
perms := currentAccountPermissions(ctx)
nav := app.accountNavView(ctx, "dashboard")
totalSize := int64(0)
activeBoxes := 0
recentBoxes := []accountDashboardBoxRow{}
if perms.AdminBoxesView {
summaries, err := boxstore.ListBoxSummaries()
if err != nil {
return AccountDashboardView{}, err
}
recentBoxes = make([]accountDashboardBoxRow, 0, minInt(len(summaries), 10))
for _, summary := range summaries {
totalSize += summary.TotalSize
if !summary.Expired {
activeBoxes++
}
if len(recentBoxes) < 10 {
recentBoxes = append(recentBoxes, accountDashboardBoxRow{
ID: summary.ID,
FileCount: summary.FileCount,
TotalSizeLabel: summary.TotalSizeLabel,
CreatedAt: formatAdminTime(summary.CreatedAt),
ExpiresAt: formatAdminTime(summary.ExpiresAt),
Flags: accountBoxFlags(summary.Expired, summary.OneTimeDownload, summary.PasswordProtected),
CanManage: true,
})
}
}
}
stats := AccountDashboardStats{
ActiveBoxes: activeBoxes,
StorageUsedLabel: helpers.FormatBytes(totalSize),
}
alertPreview := []accountAlertPreviewRow{}
if perms.AdminAccess {
stats.AlertCount, nav.AlertSeverity = app.openAlertSummary()
nav.AlertCount = stats.AlertCount
alertPreview = app.accountDashboardAlertPreview()
}
showUsersStat := perms.AdminUsersManage
if showUsersStat {
users, err := app.store.ListUsers()
if err != nil {
return AccountDashboardView{}, err
}
stats.TotalUsers = len(users)
for _, user := range users {
if user.Disabled {
stats.DisabledUsers++
} else {
stats.ActiveUsers++
}
}
}
return AccountDashboardView{
PageTitle: "WarpBox Account",
WindowTitle: "WarpBox Account Control Panel",
WindowIcon: "W",
AccountNav: nav,
CSRFToken: app.currentCSRFToken(ctx),
Stats: stats,
Statuses: app.accountDashboardStatuses(),
Alerts: alertPreview,
RecentBoxes: recentBoxes,
RecentActivity: accountPlaceholderActivity(actor, ctx),
ShowUsersStat: showUsersStat,
CanManageBoxes: perms.AdminBoxesView,
CanManageUsers: perms.AdminUsersManage,
CanViewSettings: perms.AdminSettingsManage,
HasAlertsPreview: true,
}, nil
}
func (app *App) accountDashboardStatuses() []accountStatusRow {
return []accountStatusRow{
{Label: "Guest uploads", Value: enabledLabel(app.config.GuestUploadsEnabled), Severity: boolSeverity(app.config.GuestUploadsEnabled)},
{Label: "API", Value: enabledLabel(app.config.APIEnabled), Severity: boolSeverity(app.config.APIEnabled)},
{Label: "ZIP downloads", Value: enabledLabel(app.config.ZipDownloadsEnabled), Severity: boolSeverity(app.config.ZipDownloadsEnabled)},
{Label: "One-time boxes", Value: enabledLabel(app.config.OneTimeDownloadsEnabled), Severity: boolSeverity(app.config.OneTimeDownloadsEnabled)},
}
}
func (app *App) accountDashboardAlertPreview() []accountAlertPreviewRow {
alerts, err := app.store.ListAlerts(metastore.AlertFilters{Status: metastore.AlertStatusOpen, Sort: "severity"})
if err != nil {
return nil
}
rows := make([]accountAlertPreviewRow, 0, minInt(len(alerts), 6))
for _, alert := range alerts {
if len(rows) == 6 {
break
}
rows = append(rows, accountAlertPreviewRow{
Severity: alert.Severity,
Title: alert.Title,
Detail: alert.Description,
})
}
return rows
}
func accountPlaceholderActivity(actor metastore.User, ctx *gin.Context) []accountActivityRow {
now := time.Now().UTC()
if value, ok := ctx.Get("accountSession"); ok {
if session, ok := value.(metastore.Session); ok {
now = session.CreatedAt
}
}
return []accountActivityRow{
{
Time: formatAdminTime(now),
Title: "Signed in",
Meta: actor.Username + " opened the account dashboard.",
},
{
Time: "pending",
Title: "Audit log not implemented",
Meta: "Recent account activity will use the audit model in a later pass.",
},
}
}
func accountBoxFlags(expired bool, oneTime bool, passwordProtected bool) string {
flags := []string{}
if expired {
flags = append(flags, "expired")
}
if oneTime {
flags = append(flags, "one-time")
}
if passwordProtected {
flags = append(flags, "password")
}
if len(flags) == 0 {
return "normal"
}
out := flags[0]
for _, flag := range flags[1:] {
out += ", " + flag
}
return out
}
func enabledLabel(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
func boolSeverity(enabled bool) string {
if enabled {
return "ok"
}
return "warn"
}
func minInt(a int, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -0,0 +1,506 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
type SettingsView struct {
PageTitle string
WindowTitle string
WindowIcon string
PageScripts []string
AccountNav AccountNavView
CSRFToken string
Groups []SettingsGroupView
OverridesAllowed bool
CanEdit bool
Error string
Notice string
}
type SettingsGroupView struct {
Key string
Label string
Description string
Rows []SettingsRowView
}
type SettingsRowView struct {
Key string
Label string
Description string
Type config.SettingType
Value string
DisplayValue string
Source string
EnvName string
Editable bool
LockedReason string
Future bool
}
type SettingsBackup struct {
Version int `json:"version"`
ExportedAt string `json:"exported_at"`
Settings map[string]string `json:"settings"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type ImportResult struct {
Applied int `json:"applied"`
Keys []string `json:"keys"`
}
type settingsMeta struct {
Group string
Description string
Units string
Future bool
}
var settingsGroups = []SettingsGroupView{
{Key: "uploads", Label: "Uploads", Description: "Guest uploads and upload size defaults."},
{Key: "downloads", Label: "Downloads", Description: "ZIP and one-time download behavior."},
{Key: "retention", Label: "Retention", Description: "Expiry and renewal defaults."},
{Key: "accounts", Label: "Accounts", Description: "Session and account defaults."},
{Key: "api", Label: "API", Description: "API surface toggles."},
{Key: "storage", Label: "Storage", Description: "Storage paths and hard capacity limits."},
{Key: "workers", Label: "Workers", Description: "Background worker timing."},
{Key: "box_policy", Label: "Box policy", Description: "Defaults for future owner-managed boxes."},
}
var settingsMetadata = map[string]settingsMeta{
config.SettingGuestUploadsEnabled: {Group: "uploads", Description: "Allow guests to create upload boxes."},
config.SettingDefaultUserMaxFileBytes: {Group: "uploads", Description: "Default per-user file size limit. Zero means unlimited.", Units: "bytes"},
config.SettingDefaultUserMaxBoxBytes: {Group: "uploads", Description: "Default per-user total box size limit. Zero means unlimited.", Units: "bytes"},
config.SettingZipDownloadsEnabled: {Group: "downloads", Description: "Allow ZIP downloads when a box permits it."},
config.SettingOneTimeDownloadsEnabled: {Group: "downloads", Description: "Allow one-time ZIP handoff boxes."},
config.SettingOneTimeDownloadExpirySecs: {Group: "downloads", Description: "How long one-time downloads stay retryable or pending.", Units: "duration"},
config.SettingOneTimeDownloadRetryFail: {Group: "downloads", Description: "Keep one-time boxes retryable after a ZIP writer failure."},
config.SettingDefaultGuestExpirySecs: {Group: "retention", Description: "Default guest box expiry.", Units: "duration"},
config.SettingMaxGuestExpirySecs: {Group: "retention", Description: "Maximum guest box expiry.", Units: "duration"},
config.SettingRenewOnAccessEnabled: {Group: "retention", Description: "Allow expiry renewal when a box is opened."},
config.SettingRenewOnDownloadEnabled: {Group: "retention", Description: "Allow expiry renewal when files are downloaded."},
config.SettingSessionTTLSeconds: {Group: "accounts", Description: "Account session lifetime.", Units: "duration"},
config.SettingAPIEnabled: {Group: "api", Description: "Expose API-style upload/status endpoints."},
config.SettingDataDir: {Group: "storage", Description: "Base data directory. Environment only."},
config.SettingGlobalMaxFileSizeBytes: {Group: "storage", Description: "Hard global file size cap. Environment only.", Units: "bytes"},
config.SettingGlobalMaxBoxSizeBytes: {Group: "storage", Description: "Hard global box size cap. Environment only.", Units: "bytes"},
config.SettingBoxPollIntervalMS: {Group: "workers", Description: "Browser polling cadence for box status.", Units: "milliseconds"},
config.SettingThumbnailBatchSize: {Group: "workers", Description: "Thumbnail worker batch size."},
config.SettingThumbnailIntervalSeconds: {Group: "workers", Description: "Thumbnail worker interval.", Units: "duration"},
config.SettingBoxOwnerEditEnabled: {Group: "box_policy", Description: "Default: owners may edit their boxes."},
config.SettingBoxOwnerRefreshEnabled: {Group: "box_policy", Description: "Default: owners may refresh box expiry."},
config.SettingBoxOwnerMaxRefreshCount: {Group: "box_policy", Description: "Default maximum number of owner refreshes."},
config.SettingBoxOwnerMaxRefreshAmount: {Group: "box_policy", Description: "Default maximum expiry added per owner refresh.", Units: "duration"},
config.SettingBoxOwnerMaxTotalExpiry: {Group: "box_policy", Description: "Default maximum total box expiry for owner-managed boxes.", Units: "duration"},
config.SettingBoxOwnerPasswordEdit: {Group: "box_policy", Description: "Default: owners may edit box passwords."},
}
func (app *App) handleAccountSettings(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
view, err := app.ListSettings(ctx, actor)
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.HTML(http.StatusOK, "account_settings.html", view)
}
func (app *App) handleAccountSettingsPost(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := ctx.Request.ParseForm(); err != nil {
app.renderSettingsWithMessage(ctx, actor, "could not parse settings form", "")
return
}
editable := map[string]config.SettingDefinition{}
for _, def := range config.EditableDefinitions() {
editable[def.Key] = def
}
for key := range ctx.Request.PostForm {
if key == "csrf_token" {
continue
}
if _, ok := editable[key]; ok {
continue
}
if _, ok := config.Definition(key); ok {
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("setting %q is locked", key), "")
return
}
app.renderSettingsWithMessage(ctx, actor, fmt.Sprintf("unknown setting %q", key), "")
return
}
changes := map[string]string{}
for _, def := range editable {
if def.Type == config.SettingTypeBool {
value := "false"
if ctx.PostForm(def.Key) == "true" {
value = "true"
}
changes[def.Key] = value
continue
}
if _, exists := ctx.GetPostForm(def.Key); exists {
changes[def.Key] = ctx.PostForm(def.Key)
}
}
if err := app.UpdateSettings(ctx, actor, changes); err != nil {
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
return
}
ctx.Redirect(http.StatusSeeOther, "/account/settings")
}
func (app *App) handleAccountSettingsReset(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if err := app.ResetSettingOverride(ctx, actor, ctx.PostForm("key")); err != nil {
app.renderSettingsWithMessage(ctx, actor, err.Error(), "")
return
}
ctx.Redirect(http.StatusSeeOther, "/account/settings")
}
func (app *App) handleAccountSettingsExport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
backup, err := app.ExportSettings(ctx, actor)
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
ctx.Header("Content-Disposition", `attachment; filename="warpbox-settings.json"`)
ctx.JSON(http.StatusOK, backup)
}
func (app *App) handleAccountSettingsImport(ctx *gin.Context) {
actor, ok := currentAccountUser(ctx)
if !ok {
ctx.Redirect(http.StatusSeeOther, "/account/login")
return
}
if !strings.HasPrefix(strings.ToLower(ctx.GetHeader("Content-Type")), "application/json") {
ctx.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "settings import requires application/json"})
return
}
var backup SettingsBackup
if err := json.NewDecoder(ctx.Request.Body).Decode(&backup); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid settings JSON"})
return
}
result, err := app.ImportSettings(ctx, actor, backup)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, result)
}
func (app *App) ListSettings(ctx *gin.Context, actor metastore.User) (SettingsView, error) {
perms := currentAccountPermissions(ctx)
if !perms.AdminSettingsManage {
return SettingsView{}, fmt.Errorf("permission denied")
}
rows := app.settingsRows(perms.AdminSettingsManage && app.config.AllowAdminSettingsOverride)
groups := make([]SettingsGroupView, 0, len(settingsGroups))
for _, group := range settingsGroups {
copyGroup := group
copyGroup.Rows = rows[group.Key]
groups = append(groups, copyGroup)
}
return SettingsView{
PageTitle: "WarpBox Settings",
WindowTitle: "WarpBox Account Settings",
WindowIcon: "S",
PageScripts: []string{"/static/js/account-settings.js"},
AccountNav: app.accountNavView(ctx, "settings"),
CSRFToken: app.currentCSRFToken(ctx),
Groups: groups,
OverridesAllowed: app.config.AllowAdminSettingsOverride,
CanEdit: app.config.AllowAdminSettingsOverride,
}, nil
}
func (app *App) UpdateSettings(ctx *gin.Context, actor metastore.User, changes map[string]string) error {
if err := app.requireSettingsEdit(ctx); err != nil {
return err
}
if !app.config.AllowAdminSettingsOverride {
return fmt.Errorf("admin settings overrides are disabled")
}
if err := validateSettingChanges(changes); err != nil {
return err
}
for key, value := range changes {
if err := app.store.SetSetting(key, value); err != nil {
return err
}
}
return app.reloadRuntimeConfig()
}
func (app *App) ResetSettingOverride(ctx *gin.Context, actor metastore.User, key string) error {
if err := app.requireSettingsEdit(ctx); err != nil {
return err
}
def, ok := config.Definition(strings.TrimSpace(key))
if !ok {
return fmt.Errorf("unknown setting %q", key)
}
if !def.Editable || def.HardLimit {
return fmt.Errorf("setting %q cannot be reset from account settings", key)
}
if err := app.store.DeleteSetting(def.Key); err != nil {
return err
}
return app.reloadRuntimeConfig()
}
func (app *App) ExportSettings(ctx *gin.Context, actor metastore.User) (SettingsBackup, error) {
perms := currentAccountPermissions(ctx)
if !perms.AdminSettingsManage {
return SettingsBackup{}, fmt.Errorf("permission denied")
}
settings := map[string]string{}
for _, def := range config.EditableDefinitions() {
settings[def.Key] = app.config.SettingValue(def.Key)
}
return SettingsBackup{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Settings: settings,
Metadata: map[string]string{
"app": "WarpBox",
},
}, nil
}
func (app *App) ImportSettings(ctx *gin.Context, actor metastore.User, backup SettingsBackup) (ImportResult, error) {
if err := app.requireSettingsEdit(ctx); err != nil {
return ImportResult{}, err
}
if !app.config.AllowAdminSettingsOverride {
return ImportResult{}, fmt.Errorf("admin settings overrides are disabled")
}
if backup.Settings == nil {
return ImportResult{}, fmt.Errorf("settings backup has no settings")
}
if err := validateSettingChanges(backup.Settings); err != nil {
return ImportResult{}, err
}
keys := make([]string, 0, len(backup.Settings))
for key := range backup.Settings {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if err := app.store.SetSetting(key, backup.Settings[key]); err != nil {
return ImportResult{}, err
}
}
if err := app.reloadRuntimeConfig(); err != nil {
return ImportResult{}, err
}
return ImportResult{Applied: len(keys), Keys: keys}, nil
}
func (app *App) renderSettingsWithMessage(ctx *gin.Context, actor metastore.User, errorMessage string, notice string) {
view, err := app.ListSettings(ctx, actor)
if err != nil {
ctx.String(http.StatusForbidden, "Permission denied")
return
}
view.Error = errorMessage
view.Notice = notice
ctx.HTML(http.StatusOK, "account_settings.html", view)
}
func (app *App) requireSettingsEdit(ctx *gin.Context) error {
perms := currentAccountPermissions(ctx)
if !perms.AdminSettingsManage {
return fmt.Errorf("permission denied")
}
return nil
}
func (app *App) settingsRows(canEdit bool) map[string][]SettingsRowView {
out := map[string][]SettingsRowView{}
for _, row := range app.config.SettingRows() {
meta := settingsMetadata[row.Definition.Key]
group := meta.Group
if group == "" {
group = "accounts"
}
editable := canEdit && row.Definition.Editable && !row.Definition.HardLimit
out[group] = append(out[group], SettingsRowView{
Key: row.Definition.Key,
Label: row.Definition.Label,
Description: meta.Description,
Type: row.Definition.Type,
Value: row.Value,
DisplayValue: settingDisplayValue(row.Value, meta.Units),
Source: settingSourceLabel(row.Source, row.Definition),
EnvName: row.Definition.EnvName,
Editable: editable,
LockedReason: settingLockedReason(row.Definition, canEdit),
Future: meta.Future,
})
}
return out
}
func validateSettingChanges(changes map[string]string) error {
if len(changes) == 0 {
return fmt.Errorf("no settings provided")
}
cfg, err := config.Load()
if err != nil {
return err
}
for key, value := range changes {
if _, ok := config.Definition(key); !ok {
return fmt.Errorf("unknown setting %q", key)
}
if err := cfg.ApplyOverride(key, value); err != nil {
return err
}
}
return nil
}
func (app *App) reloadRuntimeConfig() error {
cfg, err := config.Load()
if err != nil {
return err
}
overrides, err := app.store.ListSettings()
if err != nil {
return err
}
if err := cfg.ApplyOverrides(overrides); err != nil {
return err
}
app.config = cfg
applyBoxstoreRuntimeConfig(cfg)
return nil
}
func settingSourceLabel(source config.Source, def config.SettingDefinition) string {
if def.HardLimit {
return "hard env"
}
if !def.Editable {
return "locked"
}
switch source {
case config.SourceDB:
return "override"
case config.SourceEnv:
return "env"
default:
return "default"
}
}
func settingLockedReason(def config.SettingDefinition, canEdit bool) string {
if !canEdit {
return "settings changes disabled"
}
if def.HardLimit {
return "hard environment limit"
}
if !def.Editable {
return "runtime editing not supported"
}
return ""
}
func settingDisplayValue(value string, units string) string {
switch units {
case "bytes":
parsed, ok := parseInt64String(value)
if !ok {
return value
}
if parsed == 0 {
return "unlimited"
}
return fmt.Sprintf("%s (%s bytes)", formatBytesForSettings(parsed), value)
case "duration":
parsed, ok := parseInt64String(value)
if !ok {
return value
}
return fmt.Sprintf("%s (%s seconds)", formatDurationForSettings(parsed), value)
case "milliseconds":
return value + " ms"
default:
return value
}
}
func parseInt64String(value string) (int64, bool) {
var parsed int64
if _, err := fmt.Sscan(strings.TrimSpace(value), &parsed); err != nil {
return 0, false
}
return parsed, true
}
func formatBytesForSettings(value int64) string {
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
size := float64(value)
unit := 0
for size >= 1024 && unit < len(units)-1 {
size /= 1024
unit++
}
return fmt.Sprintf("%.1f %s", size, units[unit])
}
func formatDurationForSettings(seconds int64) string {
switch {
case seconds == 0:
return "none"
case seconds%86400 == 0:
return fmt.Sprintf("%d days", seconds/86400)
case seconds%3600 == 0:
return fmt.Sprintf("%d hours", seconds/3600)
case seconds%60 == 0:
return fmt.Sprintf("%d minutes", seconds/60)
default:
return fmt.Sprintf("%d seconds", seconds)
}
}

View File

@@ -0,0 +1,197 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
func TestAccountSettingsPermissionDenied(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("regular", "regular@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected permission denied, got %d", response.Code)
}
}
func TestAccountSettingsPageLoadsForAdmin(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
request := httptest.NewRequest(http.MethodGet, "/account/settings", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected settings page, got %d body=%s", response.Code, response.Body.String())
}
for _, text := range []string{"Uploads", "Downloads", "Box policy", "Save Settings"} {
if !strings.Contains(response.Body.String(), text) {
t.Fatalf("expected settings page to contain %q", text)
}
}
}
func TestAccountSettingsValidUpdate(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set(config.SettingAPIEnabled, "false")
response := postAccountSettingsForm(router, session, form)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected settings redirect, got %d body=%s", response.Code, response.Body.String())
}
if app.config.APIEnabled {
t.Fatal("expected API setting to be disabled")
}
value, ok, err := app.store.GetSetting(config.SettingAPIEnabled)
if err != nil || !ok || value != "false" {
t.Fatalf("expected API setting override false, got value=%q ok=%v err=%v", value, ok, err)
}
}
func TestAccountSettingsInvalidUpdate(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set(config.SettingSessionTTLSeconds, "1")
response := postAccountSettingsForm(router, session, form)
if response.Code != http.StatusOK {
t.Fatalf("expected settings form render, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "must be at least 60") {
t.Fatal("expected validation error in response")
}
}
func TestAccountSettingsLockedSettingCannotChange(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
form := url.Values{}
form.Set("csrf_token", session.CSRFToken)
form.Set(config.SettingGlobalMaxFileSizeBytes, "1")
response := postAccountSettingsForm(router, session, form)
if response.Code != http.StatusOK {
t.Fatalf("expected settings form render, got %d", response.Code)
}
if !strings.Contains(response.Body.String(), "locked") {
t.Fatal("expected locked setting error")
}
if value, ok, err := app.store.GetSetting(config.SettingGlobalMaxFileSizeBytes); err != nil || ok || value != "" {
t.Fatalf("expected no locked setting override, got value=%q ok=%v err=%v", value, ok, err)
}
}
func TestAccountSettingsImportRejectsUnknownOrInvalidSettings(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
for _, body := range []string{
`{"version":1,"settings":{"not_real":"true"}}`,
`{"version":1,"settings":{"session_ttl_seconds":"1"}}`,
} {
response := postAccountSettingsJSON(router, session, body)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected bad import for %s, got %d", body, response.Code)
}
}
}
func TestAccountSettingsImportAppliesValidSettings(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
response := postAccountSettingsJSON(router, session, `{"version":1,"settings":{"api_enabled":"false","box_owner_max_refresh_count":"7"}}`)
if response.Code != http.StatusOK {
t.Fatalf("expected import success, got %d body=%s", response.Code, response.Body.String())
}
if app.config.APIEnabled {
t.Fatal("expected imported API setting to be disabled")
}
if app.config.BoxOwnerMaxRefreshCount != 7 {
t.Fatalf("expected imported box owner refresh count 7, got %d", app.config.BoxOwnerMaxRefreshCount)
}
}
func TestAccountSettingsExportShape(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session := createAccountTestSession(t, app, user)
request := httptest.NewRequest(http.MethodGet, "/account/settings/export.json", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected export success, got %d", response.Code)
}
var backup SettingsBackup
if err := json.Unmarshal(response.Body.Bytes(), &backup); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if backup.Version != 1 {
t.Fatalf("expected version 1, got %d", backup.Version)
}
if _, ok := backup.Settings[config.SettingBoxOwnerMaxRefreshCount]; !ok {
t.Fatal("expected export to include box owner policy setting")
}
if _, ok := backup.Settings[config.SettingDataDir]; ok {
t.Fatal("did not expect locked data dir in export settings")
}
}
func createAccountTestSession(t *testing.T, app *App, user metastore.User) metastore.Session {
t.Helper()
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
return session
}
func postAccountSettingsForm(router http.Handler, session metastore.Session, form url.Values) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, "/account/settings", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func postAccountSettingsJSON(router http.Handler, session metastore.Session, body string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, "/account/settings/import.json", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-CSRF-Token", session.CSRFToken)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}

245
lib/server/account_test.go Normal file
View File

@@ -0,0 +1,245 @@
package server
import (
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/boxstore"
"warpbox/lib/config"
"warpbox/lib/metastore"
)
func TestAccountLoginSuccess(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
response := postAccountLogin(router, "admin", "secret")
if response.Code != http.StatusSeeOther {
t.Fatalf("expected login redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/account" {
t.Fatalf("expected redirect to /account, got %q", location)
}
if cookie := findResponseCookie(response, accountSessionCookie); cookie == nil || cookie.Value == "" {
t.Fatal("expected account session cookie")
}
}
func TestAccountLoginFailure(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
response := postAccountLogin(router, "admin", "wrong")
if response.Code != http.StatusOK {
t.Fatalf("expected failed login to render form, got %d", response.Code)
}
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
t.Fatal("did not expect account session cookie")
}
if !strings.Contains(response.Body.String(), "not accepted") {
t.Fatal("expected login failure message")
}
}
func TestAccountDisabledUserLoginFailure(t *testing.T) {
app, user := setupAccountTestApp(t)
user.Disabled = true
if err := app.store.UpdateUser(user); err != nil {
t.Fatalf("UpdateUser returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
response := postAccountLogin(router, "admin", "secret")
if response.Code != http.StatusOK {
t.Fatalf("expected disabled login to render form, got %d", response.Code)
}
if cookie := findResponseCookie(response, accountSessionCookie); cookie != nil {
t.Fatal("did not expect account session cookie")
}
if !strings.Contains(response.Body.String(), "not accepted") {
t.Fatal("expected login failure message")
}
}
func TestAccountLogoutRequiresCSRF(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/account/logout", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("expected missing CSRF token to be forbidden, got %d", response.Code)
}
}
func TestAccountDashboardRequiresAuth(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
request := httptest.NewRequest(http.MethodGet, "/account", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected dashboard redirect, got %d", response.Code)
}
if location := response.Header().Get("Location"); location != "/account/login" {
t.Fatalf("expected redirect to /account/login, got %q", location)
}
}
func TestAccountDashboardLoadsForBootstrapAdmin(t *testing.T) {
app, user := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/account", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected dashboard to load, got %d", response.Code)
}
body := response.Body.String()
for _, text := range []string{"Dashboard", "Recent Boxes", "Users"} {
if !strings.Contains(body, text) {
t.Fatalf("expected dashboard body to contain %q", text)
}
}
}
func TestAccountDashboardHidesAdminOnlyLinksForRegularUser(t *testing.T) {
app, _ := setupAccountTestApp(t)
user, err := app.store.CreateUserWithPassword("maya", "maya@example.test", "secret", nil)
if err != nil {
t.Fatalf("CreateUserWithPassword returned error: %v", err)
}
router := setupAccountTestRouter(t, app)
session, err := app.store.CreateSession(user.ID, time.Hour)
if err != nil {
t.Fatalf("CreateSession returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/account", nil)
request.AddCookie(&http.Cookie{Name: accountSessionCookie, Value: session.Token})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected dashboard to load, got %d", response.Code)
}
body := response.Body.String()
for _, text := range []string{">Users<", ">Settings<"} {
if strings.Contains(body, text) {
t.Fatalf("expected dashboard body to hide %q", text)
}
}
}
func TestAdminEntryRedirectsToAccount(t *testing.T) {
app, _ := setupAccountTestApp(t)
router := setupAccountTestRouter(t, app)
cases := map[string]string{
"/admin/login": "/account/login",
"/admin": "/account",
}
for path, wantLocation := range cases {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("expected %s redirect, got %d", path, response.Code)
}
if location := response.Header().Get("Location"); location != wantLocation {
t.Fatalf("expected %s to redirect to %s, got %q", path, wantLocation, location)
}
}
}
func setupAccountTestApp(t *testing.T) (*App, metastore.User) {
t.Helper()
gin.SetMode(gin.TestMode)
restoreUploadRoot := boxstore.UploadRoot()
t.Cleanup(func() { boxstore.SetUploadRoot(restoreUploadRoot) })
boxstore.SetUploadRoot(t.TempDir())
store, err := metastore.Open(t.TempDir())
if err != nil {
t.Fatalf("Open returned error: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
cfg.AdminUsername = "admin"
cfg.AdminPassword = "secret"
cfg.AdminEmail = "admin@example.test"
cfg.AdminEnabled = config.AdminEnabledAuto
cfg.SessionTTLSeconds = 3600
bootstrap, err := metastore.BootstrapAdmin(cfg, store)
if err != nil {
t.Fatalf("BootstrapAdmin returned error: %v", err)
}
if bootstrap.AdminUser == nil {
t.Fatal("expected bootstrap admin user")
}
app := &App{
config: cfg,
store: store,
adminLoginEnabled: bootstrap.AdminLoginEnabled,
}
return app, *bootstrap.AdminUser
}
func setupAccountTestRouter(t *testing.T, app *App) *gin.Engine {
t.Helper()
router := gin.New()
templates, err := template.ParseGlob(filepath.Join("..", "..", "templates", "*.html"))
if err != nil {
t.Fatalf("ParseGlob returned error: %v", err)
}
router.SetHTMLTemplate(templates)
app.registerAccountRoutes(router)
app.registerAdminRoutes(router)
return router
}
func postAccountLogin(router *gin.Engine, username string, password string) *httptest.ResponseRecorder {
form := url.Values{}
form.Set("username", username)
form.Set("password", password)
request := httptest.NewRequest(http.MethodPost, "/account/login", strings.NewReader(form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func findResponseCookie(response *httptest.ResponseRecorder, name string) *http.Cookie {
for _, cookie := range response.Result().Cookies() {
if cookie.Name == name {
return cookie
}
}
return nil
}

View File

@@ -181,6 +181,9 @@ func validAdminCSRF(ctx *gin.Context, session metastore.Session) bool {
} }
token := ctx.PostForm("csrf_token") token := ctx.PostForm("csrf_token")
if token == "" {
token = ctx.GetHeader("X-CSRF-Token")
}
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken) return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
} }

View File

@@ -1,18 +1,28 @@
package server package server
import "github.com/gin-gonic/gin" import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) registerAdminRoutes(router *gin.Engine) { func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin") admin := router.Group("/admin")
admin.Use(noStoreAdminHeaders) admin.Use(noStoreAdminHeaders)
admin.GET("/login", app.handleAdminLogin) admin.GET("/login", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account/login")
})
admin.POST("/login", app.handleAdminLoginPost) admin.POST("/login", app.handleAdminLoginPost)
admin.GET("", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account")
})
admin.GET("/", func(ctx *gin.Context) {
ctx.Redirect(http.StatusSeeOther, "/account")
})
protected := admin.Group("") protected := admin.Group("")
protected.Use(app.requireAdminSession) protected.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout) protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes) protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers) protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost) protected.POST("/users", app.handleAdminUsersPost)

View File

@@ -74,6 +74,7 @@ func Run(addr string) error {
DirectBoxUpload: app.handleDirectBoxUpload, DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload, LegacyUpload: app.handleLegacyUpload,
}) })
app.registerAccountRoutes(router)
app.registerAdminRoutes(router) app.registerAdminRoutes(router)
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))

View File

@@ -45,6 +45,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files}) ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": files})
} }
@@ -80,6 +81,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile}) ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "file": savedFile})
} }
@@ -116,6 +118,7 @@ func (app *App) handleFileStatusUpdate(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"file": file}) ctx.JSON(http.StatusOK, gin.H{"file": file})
} }
@@ -231,6 +234,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
savedFiles = append(savedFiles, savedFile) savedFiles = append(savedFiles, savedFile)
} }
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles}) ctx.JSON(http.StatusOK, gin.H{"box_id": boxID, "box_url": "/box/" + boxID, "files": savedFiles})
} }

1507
static/css/account.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
document.addEventListener("DOMContentLoaded", () => {
const title = document.querySelector("[data-alert-detail-title]");
const description = document.querySelector("[data-alert-detail-description]");
const metadata = document.querySelector("[data-alert-detail-metadata]");
document.querySelectorAll("[data-alert-row]").forEach((row) => {
row.addEventListener("click", (event) => {
if (event.target.closest("button, input, a")) return;
document.querySelectorAll("[data-alert-row].is-selected").forEach((item) => item.classList.remove("is-selected"));
row.classList.add("is-selected");
if (title) title.textContent = row.dataset.alertTitle || "";
if (description) description.textContent = row.dataset.alertDescription || "";
if (metadata) metadata.textContent = row.dataset.alertMetadata || "{}";
});
});
});

View File

@@ -0,0 +1,39 @@
document.addEventListener("DOMContentLoaded", () => {
const panel = document.querySelector("[data-settings-import-panel]");
const toggle = document.querySelector("[data-settings-import-toggle]");
const submit = document.querySelector("[data-settings-import-submit]");
const input = document.querySelector("[data-settings-import-json]");
const csrf = document.querySelector('input[name="csrf_token"]')?.value || "";
toggle?.addEventListener("click", () => {
if (!panel) return;
panel.hidden = !panel.hidden;
if (!panel.hidden) input?.focus();
});
submit?.addEventListener("click", async () => {
const body = input?.value.trim() || "";
if (!body) {
window.WarpBoxAccountUI.toast("Paste settings JSON first.", "warning");
return;
}
const response = await fetch("/account/settings/import.json", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
},
body,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
window.WarpBoxAccountUI.toast(payload.error || "Settings import failed.", "error");
return;
}
window.WarpBoxAccountUI.toast(`Imported ${payload.applied || 0} settings.`, "success");
window.setTimeout(() => window.location.reload(), 700);
});
});

258
static/js/account-ui.js Normal file
View File

@@ -0,0 +1,258 @@
window.WarpBoxAccountUI = (() => {
let toastTimer = null;
let activeConfirmResolve = null;
function initStickyTaskbar(options = {}) {
const taskbar = options.taskbar || document.querySelector(".top-taskbar");
if (!taskbar) return;
const update = () => {
taskbar.classList.toggle("is-scrolled", window.scrollY > 2);
};
update();
window.addEventListener("scroll", update, { passive: true });
}
function closeMenus(root = document) {
root.querySelectorAll(".menu-item.is-open").forEach((item) => {
item.classList.remove("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false");
});
}
function openMenu(item) {
if (!item) return;
closeMenus(item.closest(".menu-bar") || document);
item.classList.add("is-open");
item.querySelector(".menu-button")?.setAttribute("aria-expanded", "true");
}
function initMenus(options = {}) {
const root = options.root || document;
root.addEventListener("click", (event) => {
const button = event.target.closest(".menu-button");
if (button) {
const item = button.closest(".menu-item");
const isOpen = item?.classList.contains("is-open");
closeMenus(root);
if (!isOpen) openMenu(item);
return;
}
if (!event.target.closest(".menu-item")) {
closeMenus(root);
}
});
root.querySelectorAll(".menu-item").forEach((item) => {
item.addEventListener("mouseenter", () => {
if (!root.querySelector(".menu-item.is-open")) return;
openMenu(item);
});
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenus(root);
});
}
function toast(message, type = "info", options = {}) {
if (window.WarpBoxUI?.toast && !options.forceAccountToast) {
window.WarpBoxUI.toast(message, type, options);
return;
}
const target = options.target || document.querySelector("#account-toast") || document.querySelector("#toast");
if (!target) return;
target.textContent = message;
target.classList.remove("toast-info", "toast-success", "toast-warning", "toast-error", "is-visible");
target.classList.add(`toast-${type}`, "is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => target.classList.remove("is-visible"), options.duration || 2600);
}
function modalElements(options = {}) {
return {
modal: options.modal || document.querySelector("#account-modal"),
title: options.title || document.querySelector("#account-modal-title"),
body: options.body || document.querySelector("#account-modal-body"),
backdrop: options.backdrop || document.querySelector("#account-modal-backdrop") || document.querySelector("#modal-backdrop"),
};
}
function openModal(titleText, html, options = {}) {
const parts = modalElements(options);
if (!parts.modal || !parts.title || !parts.body) {
if (window.WarpBoxUI?.openPopup) {
window.WarpBoxUI.openPopup(titleText, html, options);
}
return;
}
parts.title.textContent = titleText;
if (options.text) {
parts.body.textContent = html;
} else {
parts.body.innerHTML = html;
}
parts.modal.classList.add("is-visible");
parts.backdrop?.classList.add("is-visible");
parts.modal.querySelector("[data-modal-close]")?.focus();
}
function closeModal(options = {}) {
const parts = modalElements(options);
parts.modal?.classList.remove("is-visible");
parts.backdrop?.classList.remove("is-visible");
if (window.WarpBoxUI?.closePopup && !parts.modal) {
window.WarpBoxUI.closePopup(options);
}
}
function confirm(message, options = {}) {
const title = options.title || "Confirm action";
const confirmLabel = options.confirmLabel || "OK";
const cancelLabel = options.cancelLabel || "Cancel";
const html = `
<p>${htmlEscape(message)}</p>
<div class="modal-actions">
<button class="win98-button" type="button" data-confirm-cancel>${htmlEscape(cancelLabel)}</button>
<button class="win98-button" type="button" data-confirm-ok>${htmlEscape(confirmLabel)}</button>
</div>
`;
const parts = modalElements(options);
if (!parts.modal) {
return Promise.resolve(window.confirm(message));
}
openModal(title, html, options);
return new Promise((resolve) => {
activeConfirmResolve = resolve;
parts.modal.querySelector("[data-confirm-ok]")?.focus();
});
}
function finishConfirm(result) {
if (activeConfirmResolve) {
activeConfirmResolve(result);
activeConfirmResolve = null;
}
closeModal();
}
function setDirtyState(isDirty, options = {}) {
const target = options.target || document.querySelector("[data-dirty-chip]");
if (!target) return;
target.classList.toggle("is-dirty", Boolean(isDirty));
target.textContent = isDirty ? (options.dirtyText || "unsaved changes") : (options.cleanText || "");
}
function bindFormDirtyState(form, options = {}) {
const targetForm = typeof form === "string" ? document.querySelector(form) : form;
if (!targetForm) return;
let baseline = new FormData(targetForm);
const serialize = () => new URLSearchParams(new FormData(targetForm)).toString();
let baselineValue = new URLSearchParams(baseline).toString();
const update = () => setDirtyState(serialize() !== baselineValue, options);
targetForm.addEventListener("input", update);
targetForm.addEventListener("change", update);
targetForm.addEventListener("submit", () => {
baseline = new FormData(targetForm);
baselineValue = new URLSearchParams(baseline).toString();
setDirtyState(false, options);
});
update();
}
function bindConfirmActions(root = document) {
root.addEventListener("click", async (event) => {
const ok = event.target.closest("[data-confirm-ok]");
if (ok) {
finishConfirm(true);
return;
}
const cancel = event.target.closest("[data-confirm-cancel], [data-modal-close]");
if (cancel) {
finishConfirm(false);
return;
}
const action = event.target.closest("[data-confirm]");
if (!action) return;
if (action.dataset.confirmAccepted === "true") {
delete action.dataset.confirmAccepted;
return;
}
const message = action.getAttribute("data-confirm");
if (!message) return;
event.preventDefault();
event.stopPropagation();
const accepted = await confirm(message, {
title: action.getAttribute("data-confirm-title") || "Confirm action",
confirmLabel: action.getAttribute("data-confirm-label") || "OK",
cancelLabel: action.getAttribute("data-cancel-label") || "Cancel",
});
if (!accepted) return;
if (action instanceof HTMLAnchorElement && action.href) {
window.location.href = action.href;
return;
}
const form = action.closest("form");
const type = (action.getAttribute("type") || "").toLowerCase();
if (form && (type === "submit" || type === "")) {
form.requestSubmit(action);
return;
}
action.dataset.confirmAccepted = "true";
action.click();
});
}
function htmlEscape(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function init(root = document) {
initStickyTaskbar();
initMenus({ root });
bindConfirmActions(root);
document.querySelector("#account-modal-backdrop")?.addEventListener("click", () => closeModal());
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeModal();
});
}
return {
init,
initStickyTaskbar,
initMenus,
toast,
confirm,
openModal,
closeModal,
setDirtyState,
bindFormDirtyState,
closeMenus,
};
})();
document.addEventListener("DOMContentLoaded", () => {
window.WarpBoxAccountUI.init();
});

View File

@@ -0,0 +1,182 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-alerts-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Alerts toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/alerts"><span>R</span><span>Refresh alerts</span><span></span></a>
<a class="menu-action" href="/account/alerts/export.json"><span>E</span><span>Export JSON</span><span></span></a>
<div class="menu-separator"></div>
<form action="/account/logout" method="post">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
</form>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/alerts?status=open"><span>O</span><span>Open</span><span></span></a>
<a class="menu-action" href="/account/alerts?severity=high"><span>H</span><span>High severity</span><span></span></a>
<a class="menu-action" href="/account/alerts?sort=severity"><span>S</span><span>Sort by severity</span><span></span></a>
</div>
</div>
</nav>
<div class="alerts-layout account-body-content">
<section class="stats-grid" aria-label="Alert statistics">
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Open</p>
<p class="stat-value">{{ .Stats.Open }}</p>
<p class="stat-note"><span class="stat-note-pill">needs attention</span></p>
</article>
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Acknowledged</p>
<p class="stat-value">{{ .Stats.Acknowledged }}</p>
<p class="stat-note"><span class="stat-note-pill">seen</span></p>
</article>
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Closed</p>
<p class="stat-value">{{ .Stats.Closed }}</p>
<p class="stat-note"><span class="stat-note-pill">done</span></p>
</article>
<article class="stat-card sunken-panel is-danger">
<p class="stat-label">High</p>
<p class="stat-value">{{ .Stats.High }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Medium }} medium</span><span class="stat-note-pill">{{ .Stats.Low }} low</span></p>
</article>
</section>
<form class="alerts-filterbar raised-panel" action="/account/alerts" method="get">
<label class="account-form-row">
<span>Search</span>
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="title, code, trace">
</label>
<label class="account-form-row">
<span>Severity</span>
<select class="account-control" name="severity">
<option value="all" {{ if eq .Filters.Severity "all" }}selected{{ end }}>All</option>
<option value="low" {{ if eq .Filters.Severity "low" }}selected{{ end }}>Low</option>
<option value="medium" {{ if eq .Filters.Severity "medium" }}selected{{ end }}>Medium</option>
<option value="high" {{ if eq .Filters.Severity "high" }}selected{{ end }}>High</option>
</select>
</label>
<label class="account-form-row">
<span>Status</span>
<select class="account-control" name="status">
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
<option value="open" {{ if eq .Filters.Status "open" }}selected{{ end }}>Open</option>
<option value="acknowledged" {{ if eq .Filters.Status "acknowledged" }}selected{{ end }}>Acknowledged</option>
<option value="closed" {{ if eq .Filters.Status "closed" }}selected{{ end }}>Closed</option>
</select>
</label>
<label class="account-form-row">
<span>Group</span>
<select class="account-control" name="group">
<option value="all" {{ if eq .Filters.Group "all" }}selected{{ end }}>All</option>
{{ range .Groups }}
<option value="{{ . }}" {{ if eq $.Filters.Group . }}selected{{ end }}>{{ . }}</option>
{{ end }}
</select>
</label>
<label class="account-form-row">
<span>Sort</span>
<select class="account-control" name="sort">
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
<option value="severity" {{ if eq .Filters.Sort "severity" }}selected{{ end }}>Severity</option>
</select>
</label>
<button class="win98-button" type="submit">Apply</button>
</form>
<section class="alerts-workspace">
<form class="win98-window section-window" action="/account/alerts/bulk/acknowledge" method="post">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">!</span>
<h2>Alert List</h2>
</div>
</div>
<div class="section-body sunken-panel">
{{ template "account_csrf_field" . }}
<div class="scroll-panel alerts-table-scroll">
<table class="account-table alerts-table">
<thead>
<tr>
<th>Select</th>
<th>Severity</th>
<th>Status</th>
<th>Code</th>
<th>Title</th>
<th>Trace</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Alerts }}
<tr data-alert-row data-alert-id="{{ .ID }}" data-alert-title="{{ .Title }}" data-alert-description="{{ .Description }}" data-alert-metadata="{{ .MetadataPretty }}" class="{{ if eq $.SelectedAlert.ID .ID }}is-selected{{ end }}">
<td><input type="checkbox" name="alert_ids" value="{{ .ID }}"></td>
<td><span class="badge is-{{ .Severity }}">{{ .Severity }}</span></td>
<td><span class="badge">{{ .Status }}</span></td>
<td>{{ .Code }}</td>
<td>{{ .Title }}</td>
<td>{{ .Trace }}</td>
<td>{{ .CreatedAt }}</td>
<td>
<div class="box-actions">
{{ if $.CanManageAlerts }}
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/acknowledge">Ack</button>
<button class="tiny-button" type="submit" formaction="/account/alerts/{{ .ID }}/close">Close</button>
{{ end }}
</div>
</td>
</tr>
{{ else }}
<tr><td colspan="8">No alerts found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
{{ if .CanManageAlerts }}
<div class="bulk-actions raised-panel">
<button class="win98-button" type="submit">Acknowledge selected</button>
<button class="win98-button" type="submit" formaction="/account/alerts/bulk/close">Close selected</button>
</div>
{{ end }}
</div>
</form>
<aside class="alerts-detail sunken-panel" aria-label="Alert details">
{{ if .SelectedAlert }}
<div>
<h2 data-alert-detail-title>{{ .SelectedAlert.Title }}</h2>
<p data-alert-detail-description>{{ .SelectedAlert.Description }}</p>
</div>
<pre class="metadata-pre" data-alert-detail-metadata>{{ .SelectedAlert.MetadataPretty }}</pre>
<div class="setting-source">
<span class="badge is-{{ .SelectedAlert.Severity }}">{{ .SelectedAlert.Severity }}</span>
<span class="badge">{{ .SelectedAlert.Status }}</span>
<span class="setting-env">{{ .SelectedAlert.Trace }}</span>
</div>
{{ else }}
<div>
<h2 data-alert-detail-title>No alert selected</h2>
<p data-alert-detail-description>Select an alert row to inspect metadata.</p>
</div>
<pre class="metadata-pre" data-alert-detail-metadata>{}</pre>
{{ end }}
</aside>
</section>
</div>
<footer class="win98-statusbar" aria-label="Alerts status">
<span>alerts</span>
<span>{{ .Stats.Open }} open</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

@@ -0,0 +1,174 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-boxes-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Boxes toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/boxes"><span>R</span><span>Refresh boxes</span><span></span></a>
<a class="menu-action" href="/account/boxes/export.csv"><span>E</span><span>Export visible CSV</span><span></span></a>
<div class="menu-separator"></div>
<form action="/account/logout" method="post">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
</form>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/boxes?status=active"><span>A</span><span>Active</span><span></span></a>
<a class="menu-action" href="/account/boxes?status=expired"><span>X</span><span>Expired</span><span></span></a>
<a class="menu-action" href="/account/boxes?sort=largest"><span>L</span><span>Largest first</span><span></span></a>
</div>
</div>
</nav>
<div class="boxes-layout account-body-content">
{{ if .Error }}<p class="account-error">{{ .Error }}</p>{{ end }}
<section class="stats-grid" aria-label="Box statistics">
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Visible</p>
<p class="stat-value">{{ .Stats.Visible }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.Total }} matching</span></p>
</article>
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Expired</p>
<p class="stat-value">{{ .Stats.Expired }}</p>
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
</article>
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Storage</p>
<p class="stat-value">{{ .Stats.Storage }}</p>
<p class="stat-note"><span class="stat-note-pill">visible page</span></p>
</article>
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Policy</p>
<p class="stat-note"><span class="stat-note-pill">{{ .PolicySummary }}</span></p>
</article>
</section>
<form class="boxes-filterbar raised-panel" action="/account/boxes" method="get">
<label class="account-form-row">
<span>Search</span>
<input class="account-control" name="q" value="{{ .Filters.Query }}" placeholder="id, owner, file">
</label>
<label class="account-form-row">
<span>Owner</span>
<input class="account-control" name="owner" value="{{ .Filters.Owner }}" placeholder="all">
</label>
<label class="account-form-row">
<span>Status</span>
<select class="account-control" name="status">
<option value="all" {{ if eq .Filters.Status "all" }}selected{{ end }}>All</option>
<option value="active" {{ if eq .Filters.Status "active" }}selected{{ end }}>Active</option>
<option value="pending" {{ if eq .Filters.Status "pending" }}selected{{ end }}>Pending</option>
<option value="expired" {{ if eq .Filters.Status "expired" }}selected{{ end }}>Expired</option>
</select>
</label>
<label class="account-form-row">
<span>Flag</span>
<select class="account-control" name="flag">
<option value="all" {{ if eq .Filters.Flag "all" }}selected{{ end }}>All</option>
<option value="password" {{ if eq .Filters.Flag "password" }}selected{{ end }}>Password</option>
<option value="one-time" {{ if eq .Filters.Flag "one-time" }}selected{{ end }}>One-time</option>
<option value="zip-disabled" {{ if eq .Filters.Flag "zip-disabled" }}selected{{ end }}>ZIP disabled</option>
<option value="expired" {{ if eq .Filters.Flag "expired" }}selected{{ end }}>Expired</option>
<option value="refreshable" {{ if eq .Filters.Flag "refreshable" }}selected{{ end }}>Refreshable</option>
</select>
</label>
<label class="account-form-row">
<span>Sort</span>
<select class="account-control" name="sort">
<option value="newest" {{ if eq .Filters.Sort "newest" }}selected{{ end }}>Newest</option>
<option value="oldest" {{ if eq .Filters.Sort "oldest" }}selected{{ end }}>Oldest</option>
<option value="largest" {{ if eq .Filters.Sort "largest" }}selected{{ end }}>Largest</option>
<option value="expires" {{ if eq .Filters.Sort "expires" }}selected{{ end }}>Expires soon</option>
<option value="expired" {{ if eq .Filters.Sort "expired" }}selected{{ end }}>Expired first</option>
</select>
</label>
<input type="hidden" name="page_size" value="{{ .PageSize }}">
<button class="win98-button" type="submit">Apply</button>
</form>
<form class="win98-window section-window" action="/account/boxes/bulk/expire" method="post">
{{ template "account_csrf_field" . }}
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">B</span>
<h2>Box Index</h2>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel boxes-table-scroll">
<table class="account-table boxes-table">
<thead>
<tr>
<th>Select</th>
<th>Box</th>
<th>Owner</th>
<th>Status</th>
<th>Files</th>
<th>Size</th>
<th>Created</th>
<th>Expires</th>
<th>Flags</th>
<th>Refresh policy</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr>
<td><input type="checkbox" name="box_ids" value="{{ .ID }}"></td>
<td>{{ .ID }}</td>
<td>{{ .Owner }}</td>
<td><span class="badge">{{ .Status }}</span></td>
<td>{{ .FileCount }}</td>
<td>{{ .Size }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ .ExpiresAt }}</td>
<td>{{ .Flags }}</td>
<td>{{ .Policy }}</td>
<td>
<div class="box-actions">
<a class="tiny-button" href="{{ .OpenURL }}">Open</a>
{{ if .CanManage }}<a class="tiny-button" href="{{ .ManageURL }}">Manage</a>{{ end }}
</div>
</td>
</tr>
{{ else }}
<tr><td colspan="11">No indexed boxes found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
<div class="bulk-actions raised-panel">
<label class="account-form-row">
<span>Bump seconds</span>
<input class="account-control" name="bump_seconds" value="3600" inputmode="numeric">
</label>
<button class="win98-button" type="submit" data-confirm="Expire selected boxes?">Expire selected</button>
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/bump-expiry">Bump selected</button>
<button class="win98-button" type="submit" formaction="/account/boxes/bulk/delete" data-confirm="Delete selected boxes permanently?">Delete selected</button>
<button class="win98-button" type="submit" formaction="/account/boxes/delete-largest" data-confirm="Delete 10 biggest matching boxes permanently?">Delete largest 10</button>
</div>
</div>
</form>
<nav class="pagination-strip raised-panel" aria-label="Pagination">
<span class="badge">Page {{ .Page }} / {{ .TotalPages }}</span>
{{ if .HasPrev }}<a class="win98-button" href="{{ .PrevURL }}">Prev</a>{{ end }}
{{ if .HasNext }}<a class="win98-button" href="{{ .NextURL }}">Next</a>{{ end }}
</nav>
</div>
<footer class="win98-statusbar" aria-label="Boxes status">
<span>boxes index</span>
<span>{{ .Total }} matching</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

@@ -0,0 +1,198 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-dashboard-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Dashboard toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account"><span>R</span><span>Refresh dashboard</span><span class="shortcut">F5</span></a>
<div class="menu-separator"></div>
<form action="/account/logout" method="post">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
</form>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="#alerts"><span>!</span><span>Go to alerts</span><span></span></a>
<a class="menu-action" href="#recent-boxes"><span>B</span><span>Go to recent boxes</span><span></span></a>
<a class="menu-action" href="#recent-activity"><span>T</span><span>Go to recent activity</span><span></span></a>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Tools</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/boxes"><span>B</span><span>Boxes</span><span></span></a>
<a class="menu-action" href="/account/alerts"><span>!</span><span>Alerts</span><span></span></a>
{{ if .CanManageUsers }}
<a class="menu-action" href="/account/users"><span>U</span><span>Users</span><span></span></a>
{{ end }}
{{ if .CanViewSettings }}
<a class="menu-action" href="/account/settings"><span>S</span><span>Settings</span><span></span></a>
{{ end }}
</div>
</div>
</nav>
<div class="account-body-content">
<section class="dashboard-hero raised-panel" aria-labelledby="account-dashboard-title">
<div class="hero-copy">
<h2 id="account-dashboard-title">Dashboard</h2>
<p>Account overview for boxes, alerts, storage, users, and recent activity.</p>
</div>
<div class="hero-status" aria-label="System summary">
{{ range .Statuses }}
<div class="hero-status-row"><span>{{ .Label }}</span><strong class="status-{{ .Severity }}">{{ .Value }}</strong></div>
{{ end }}
</div>
</section>
<section class="stats-grid" aria-label="Dashboard statistics">
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Active boxes</p>
<p class="stat-value">{{ .Stats.ActiveBoxes }}</p>
<p class="stat-note"><span class="stat-note-pill">live filesystem scan</span></p>
</article>
<article class="stat-card sunken-panel is-info">
<p class="stat-label">Storage used</p>
<p class="stat-value">{{ .Stats.StorageUsedLabel }}</p>
<p class="stat-note"><span class="stat-note-pill">local backend</span></p>
</article>
<article class="stat-card sunken-panel is-warning">
<p class="stat-label">Alerts</p>
<p class="stat-value">{{ .Stats.AlertCount }}</p>
<p class="stat-note"><span class="stat-note-pill">alert model pending</span></p>
</article>
{{ if .ShowUsersStat }}
<article class="stat-card sunken-panel is-ok">
<p class="stat-label">Users</p>
<p class="stat-value">{{ .Stats.TotalUsers }}</p>
<p class="stat-note"><span class="stat-note-pill">{{ .Stats.ActiveUsers }} active</span><span class="stat-note-pill">{{ .Stats.DisabledUsers }} disabled</span></p>
</article>
{{ end }}
</section>
<section class="main-grid" aria-label="Dashboard panels">
<article id="alerts" class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">!</span>
<h2>Alerts Preview</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/account/alerts">Show all</a>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel alerts-scroll">
<div class="alert-list">
{{ range .Alerts }}
<div class="alert-row">
<span class="badge is-{{ .Severity }}">{{ .Severity }}</span>
<div>
<p class="alert-title">{{ .Title }}</p>
<p class="alert-desc">{{ .Detail }}</p>
</div>
<div class="alert-actions">
<a class="tiny-button" href="/account/alerts">Open</a>
</div>
</div>
{{ else }}
<div class="alert-row">
<span class="badge is-ok">ok</span>
<div><p class="alert-title">No alerts</p><p class="alert-desc">Nothing needs attention.</p></div>
</div>
{{ end }}
</div>
</div>
</div>
</article>
<article id="recent-boxes" class="win98-window section-window">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">B</span>
<h2>Recent Boxes</h2>
</div>
<div class="titlebar-actions">
<a class="titlebar-link-button" href="/account/boxes">Show all</a>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel boxes-scroll">
<table class="account-table">
<thead>
<tr>
<th>Box</th>
<th>Files</th>
<th>Size</th>
<th>Created</th>
<th>Expires</th>
<th>Flags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{ range .RecentBoxes }}
<tr>
<td>{{ .ID }}</td>
<td>{{ .FileCount }}</td>
<td>{{ .TotalSizeLabel }}</td>
<td>{{ .CreatedAt }}</td>
<td>{{ .ExpiresAt }}</td>
<td>{{ .Flags }}</td>
<td>
<div class="box-actions">
<a class="tiny-button" href="/box/{{ .ID }}">Open</a>
{{ if .CanManage }}
<a class="tiny-button" href="/account/boxes/{{ .ID }}">Manage</a>
{{ end }}
</div>
</td>
</tr>
{{ else }}
<tr><td colspan="7">No boxes found.</td></tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</article>
<article id="recent-activity" class="win98-window section-window span-2">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">T</span>
<h2>Recent Activity</h2>
</div>
</div>
<div class="section-body sunken-panel">
<div class="scroll-panel activity-scroll">
<div class="activity-list">
{{ range .RecentActivity }}
<div class="activity-row">
<span class="activity-time">{{ .Time }}</span>
<div>
<p class="activity-title">{{ .Title }}</p>
<p class="activity-meta">{{ .Meta }}</p>
</div>
<span class="tag info">account</span>
</div>
{{ end }}
</div>
</div>
</div>
</article>
</section>
</div>
<footer class="win98-statusbar" aria-label="Dashboard status">
<span>signed in: {{ .AccountNav.Username }}</span>
<span>{{ if .AccountNav.IsAdmin }}admin{{ else }}account{{ end }}</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .PageTitle }}</title>
{{ template "account_head_assets" . }}
</head>
<body class="account-body">
<div class="app-shell">
<div class="app-frame">
<main class="account-window" aria-labelledby="account-login-title">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">W</span>
<h1 id="account-login-title">WarpBox Account Login</h1>
</div>
</div>
<div class="account-body-content">
{{ if .Error }}
<p class="account-error">{{ .Error }}</p>
{{ end }}
{{ if .AccountLoginEnabled }}
<form class="account-form sunken-panel" action="/account/login" method="post">
<label class="account-form-row">
<span>Username</span>
<input name="username" autocomplete="username" required>
</label>
<label class="account-form-row">
<span>Password</span>
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="win98-button" type="submit">Login</button>
</form>
{{ else }}
<p class="sunken-panel section-body">Account login is disabled. Set bootstrap admin credentials and restart to enable account access.</p>
{{ end }}
</div>
</main>
</div>
</div>
{{ template "account_toast_modal_containers" . }}
<script src="/static/js/account-ui.js"></script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
{{ define "account_head_assets" }}
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/account.css">
{{ end }}
{{ define "account_shell_start" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ if .PageTitle }}{{ .PageTitle }}{{ else }}WarpBox Account{{ end }}</title>
{{ template "account_head_assets" . }}
</head>
<body class="account-body">
<div class="app-shell">
<div class="app-frame">
{{ template "account_taskbar" . }}
{{ end }}
{{ define "account_shell_end" }}
</div>
</div>
{{ template "account_toast_modal_containers" . }}
<script src="/static/js/account-ui.js"></script>
{{ range .PageScripts }}
<script src="{{ . }}"></script>
{{ end }}
</body>
</html>
{{ end }}
{{ define "account_taskbar" }}
{{ $nav := .AccountNav }}
<header class="top-taskbar" aria-label="Account navigation">
<a class="start-button" href="/account">
<span class="start-logo">W</span>
<span>WarpBox</span>
</a>
<nav class="taskbar-nav" aria-label="Primary">
<a class="taskbar-button{{ if eq $nav.ActiveSection "dashboard" }} is-active{{ end }}" href="/account">Dashboard</a>
{{ if $nav.CanViewBoxes }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "boxes" }} is-active{{ end }}" href="/account/boxes">Boxes</a>
{{ end }}
{{ if $nav.CanViewAlerts }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "alerts" }} is-active{{ end }}" href="/account/alerts">Alerts</a>
{{ end }}
{{ if $nav.CanViewUsers }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "users" }} is-active{{ end }}" href="/account/users">Users</a>
{{ end }}
{{ if $nav.CanViewAPIKeys }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "api-keys" }} is-active{{ end }}" href="/account/api-keys">API Keys</a>
{{ end }}
{{ if $nav.CanViewSettings }}
<a class="taskbar-button{{ if eq $nav.ActiveSection "settings" }} is-active{{ end }}" href="/account/settings">Settings</a>
{{ end }}
</nav>
<div class="taskbar-session" aria-label="Current session summary">
{{ if gt $nav.AlertCount 0 }}
<a class="alert-chip is-{{ $nav.AlertSeverity }}" href="/account/alerts">! {{ $nav.AlertCount }} alerts</a>
{{ else }}
<span class="alert-chip is-ok">0 alerts</span>
{{ end }}
<span class="session-chip">signed in: {{ $nav.Username }}</span>
{{ if $nav.IsAdmin }}
<span class="session-chip">admin</span>
{{ else }}
<span class="session-chip">account</span>
{{ end }}
<span class="dirty-chip" data-dirty-chip></span>
</div>
</header>
{{ end }}
{{ define "account_window_titlebar" }}
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">{{ if .WindowIcon }}{{ .WindowIcon }}{{ else }}W{{ end }}</span>
<h1>{{ if .WindowTitle }}{{ .WindowTitle }}{{ else }}WarpBox Account Control Panel{{ end }}</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button">[]</button>
<button class="win98-control" type="button">x</button>
</div>
</div>
{{ end }}
{{ define "account_csrf_field" }}
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
{{ end }}
{{ define "account_toast_modal_containers" }}
<div class="toast" id="account-toast" role="status" aria-live="polite"></div>
<div class="modal-backdrop" id="account-modal-backdrop" aria-hidden="true"></div>
<section class="account-modal win98-window" id="account-modal" role="dialog" aria-modal="true" aria-labelledby="account-modal-title">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<span class="win98-titlebar-icon">W</span>
<h2 id="account-modal-title">WarpBox</h2>
</div>
<div class="win98-window-controls">
<button class="win98-control" type="button" data-modal-close aria-label="Close">x</button>
</div>
</div>
<div class="modal-body sunken-panel" id="account-modal-body"></div>
</section>
{{ end }}

View File

@@ -0,0 +1,134 @@
{{ template "account_shell_start" . }}
<main class="account-window" aria-labelledby="account-settings-title">
{{ template "account_window_titlebar" . }}
<nav class="menu-bar" aria-label="Settings toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup" role="menu">
<a class="menu-action" href="/account/settings"><span>R</span><span>Refresh settings</span><span></span></a>
<a class="menu-action" href="/account/settings/export.json"><span>E</span><span>Export JSON</span><span></span></a>
<div class="menu-separator"></div>
<form action="/account/logout" method="post">
{{ template "account_csrf_field" . }}
<button class="menu-action" type="submit"><span>Q</span><span>Log out</span><span></span></button>
</form>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup" role="menu">
{{ range .Groups }}
<a class="menu-action" href="#settings-{{ .Key }}"><span>S</span><span>{{ .Label }}</span><span></span></a>
{{ end }}
</div>
</div>
</nav>
<form class="settings-layout account-body-content" action="/account/settings" method="post">
{{ template "account_csrf_field" . }}
<section class="settings-summary raised-panel" aria-label="Settings status">
{{ if .Error }}<span class="badge is-danger">{{ .Error }}</span>{{ end }}
{{ if .Notice }}<span class="badge is-ok">{{ .Notice }}</span>{{ end }}
{{ if .OverridesAllowed }}
<span class="badge is-ok">overrides enabled</span>
{{ else }}
<span class="badge is-warning">read-only: overrides disabled</span>
{{ end }}
<a class="tiny-button" href="/account/settings/export.json">Export JSON</a>
<button class="tiny-button" type="button" data-settings-import-toggle>Import JSON</button>
</section>
<section class="settings-import raised-panel" data-settings-import-panel hidden>
<label class="account-form-row">
<span>Settings backup JSON</span>
<textarea class="account-control" rows="5" data-settings-import-json></textarea>
</label>
<button class="win98-button" type="button" data-settings-import-submit {{ if not .CanEdit }}disabled{{ end }}>Import</button>
</section>
<div class="settings-scroll scroll-panel" aria-label="Grouped settings">
{{ range .Groups }}
<section class="settings-group" id="settings-{{ .Key }}">
<header class="settings-group-header">
<h2>{{ .Label }}</h2>
<p>{{ .Description }}</p>
</header>
<table class="account-table settings-table">
<thead>
<tr>
<th>Setting</th>
<th>Description</th>
<th>Value</th>
<th>Source</th>
<th>Reset</th>
</tr>
</thead>
<tbody>
{{ range .Rows }}
<tr>
<td>
<strong>{{ .Label }}</strong>
<span class="setting-key">{{ .Key }}</span>
</td>
<td><p class="setting-description">{{ .Description }}</p></td>
<td>
{{ if .Editable }}
{{ if eq .Type "bool" }}
<label class="account-checks"><span><input type="checkbox" name="{{ .Key }}" value="true" {{ if eq .Value "true" }}checked{{ end }}> enabled</span></label>
{{ else }}
<input class="account-control" name="{{ .Key }}" value="{{ .Value }}" inputmode="numeric">
<span class="setting-key">{{ .DisplayValue }}</span>
{{ end }}
{{ else }}
<span>{{ .DisplayValue }}</span>
{{ if .LockedReason }}<span class="setting-key">{{ .LockedReason }}</span>{{ end }}
{{ end }}
</td>
<td>
<span class="setting-source">
<span class="badge is-info">{{ .Source }}</span>
<span class="setting-env">{{ .EnvName }}</span>
</span>
</td>
<td>
{{ if .Editable }}
<button class="tiny-button" type="submit" form="reset-{{ .Key }}">Reset</button>
{{ else }}
<span class="badge">locked</span>
{{ end }}
</td>
</tr>
{{ else }}
<tr><td colspan="5">No settings in this group.</td></tr>
{{ end }}
</tbody>
</table>
</section>
{{ end }}
</div>
<section class="settings-actions raised-panel" aria-label="Settings actions">
<button class="win98-button" type="submit" {{ if not .CanEdit }}disabled{{ end }}>Save Settings</button>
</section>
</form>
{{ range .Groups }}
{{ range .Rows }}
{{ if .Editable }}
<form id="reset-{{ .Key }}" action="/account/settings/reset" method="post" hidden>
{{ template "account_csrf_field" $ }}
<input type="hidden" name="key" value="{{ .Key }}">
</form>
{{ end }}
{{ end }}
{{ end }}
<footer class="win98-statusbar" aria-label="Settings status">
<span>settings</span>
<span>{{ if .CanEdit }}editable{{ else }}read-only{{ end }}</span>
<span>ready</span>
</footer>
</main>
{{ template "account_shell_end" . }}