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)
if loadErr == nil {
for i := range extra {
extra[i].Default = extra[i].Default
}
}
rows = append(rows, extra...)
return rows

View File

@@ -28,6 +28,12 @@ func TestDefaults(t *testing.T) {
if cfg.AdminPassword != "" {
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) {
@@ -39,6 +45,8 @@ func TestEnvironmentOverrides(t *testing.T) {
t.Setenv("WARPBOX_BOX_POLL_INTERVAL_MS", "2000")
t.Setenv("WARPBOX_ADMIN_USERNAME", "root")
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()
if err != nil {
@@ -63,6 +71,9 @@ func TestEnvironmentOverrides(t *testing.T) {
if !cfg.OneTimeDownloadRetryOnFailure {
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 {
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 {
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) {
@@ -181,6 +198,12 @@ func clearConfigEnv(t *testing.T) {
"WARPBOX_BOX_POLL_INTERVAL_MS",
"WARPBOX_THUMBNAIL_BATCH_SIZE",
"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, "")
}

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: 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: 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 {
@@ -38,6 +44,10 @@ func (cfg *Config) Source(key string) Source {
return cfg.sourceFor(key)
}
func (cfg *Config) SettingValue(key string) string {
return cfg.values[key]
}
func (cfg *Config) AdminLoginEnabled(hasAdminUser bool) bool {
switch cfg.AdminEnabled {
case AdminEnabledFalse:

View File

@@ -11,24 +11,30 @@ import (
func Load() (*Config, error) {
cfg := &Config{
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
sources: make(map[string]Source),
values: make(map[string]string),
DataDir: "./data",
AdminUsername: "admin",
AdminEnabled: AdminEnabledAuto,
AllowAdminSettingsOverride: true,
GuestUploadsEnabled: true,
APIEnabled: true,
ZipDownloadsEnabled: true,
OneTimeDownloadsEnabled: true,
OneTimeDownloadExpirySeconds: 7 * 24 * 60 * 60,
OneTimeDownloadRetryOnFailure: false,
DefaultGuestExpirySeconds: 10,
MaxGuestExpirySeconds: 48 * 60 * 60,
SessionTTLSeconds: 24 * 60 * 60,
BoxPollIntervalMS: 5000,
ThumbnailBatchSize: 10,
ThumbnailIntervalSeconds: 30,
BoxOwnerEditEnabled: true,
BoxOwnerRefreshEnabled: true,
BoxOwnerMaxRefreshCount: 3,
BoxOwnerMaxRefreshAmountSeconds: 24 * 60 * 60,
BoxOwnerMaxTotalExpirySeconds: 7 * 24 * 60 * 60,
BoxOwnerPasswordEditEnabled: true,
sources: make(map[string]Source),
values: make(map[string]string),
}
// Config precedence: defaults -> env -> overrides.
@@ -73,6 +79,9 @@ func Load() (*Config, error) {
{SettingOneTimeDownloadRetryFail, "WARPBOX_ONE_TIME_DOWNLOAD_RETRY_ON_FAILURE", &cfg.OneTimeDownloadRetryOnFailure},
{SettingRenewOnAccessEnabled, "WARPBOX_RENEW_ON_ACCESS_ENABLED", &cfg.RenewOnAccessEnabled},
{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 {
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},
{SettingOneTimeDownloadExpirySecs, "WARPBOX_ONE_TIME_DOWNLOAD_EXPIRY_SECONDS", 0, &cfg.OneTimeDownloadExpirySeconds},
{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 {
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},
{SettingThumbnailBatchSize, "WARPBOX_THUMBNAIL_BATCH_SIZE", 1, &cfg.ThumbnailBatchSize},
{SettingThumbnailIntervalSeconds, "WARPBOX_THUMBNAIL_INTERVAL_SECONDS", 1, &cfg.ThumbnailIntervalSeconds},
{SettingBoxOwnerMaxRefreshCount, "WARPBOX_BOX_OWNER_MAX_REFRESH_COUNT", 0, &cfg.BoxOwnerMaxRefreshCount},
}
for _, item := range envInts {
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(SettingThumbnailBatchSize, strconv.Itoa(cfg.ThumbnailBatchSize), 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 {

View File

@@ -36,6 +36,12 @@ const (
SettingThumbnailBatchSize = "thumbnail_batch_size"
SettingThumbnailIntervalSeconds = "thumbnail_interval_seconds"
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
@@ -84,16 +90,22 @@ type Config struct {
RenewOnAccessEnabled bool
RenewOnDownloadEnabled bool
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
DefaultGuestExpirySeconds int64
MaxGuestExpirySeconds int64
GlobalMaxFileSizeBytes int64
GlobalMaxBoxSizeBytes int64
DefaultUserMaxFileSizeBytes int64
DefaultUserMaxBoxSizeBytes int64
SessionTTLSeconds int64
BoxPollIntervalMS int
ThumbnailBatchSize int
ThumbnailIntervalSeconds int
BoxOwnerEditEnabled bool
BoxOwnerRefreshEnabled bool
BoxOwnerMaxRefreshCount int
BoxOwnerMaxRefreshAmountSeconds int64
BoxOwnerMaxTotalExpirySeconds int64
BoxOwnerPasswordEditEnabled bool
sources map[string]Source
values map[string]string

View File

@@ -64,6 +64,12 @@ func (cfg *Config) assignBool(key string, value bool, source Source) {
cfg.RenewOnAccessEnabled = value
case SettingRenewOnDownloadEnabled:
cfg.RenewOnDownloadEnabled = value
case SettingBoxOwnerEditEnabled:
cfg.BoxOwnerEditEnabled = value
case SettingBoxOwnerRefreshEnabled:
cfg.BoxOwnerRefreshEnabled = value
case SettingBoxOwnerPasswordEdit:
cfg.BoxOwnerPasswordEditEnabled = value
}
cfg.setValue(key, formatBool(value), source)
}
@@ -82,6 +88,10 @@ func (cfg *Config) assignInt64(key string, value int64, source Source) {
cfg.DefaultUserMaxBoxSizeBytes = value
case SettingSessionTTLSeconds:
cfg.SessionTTLSeconds = value
case SettingBoxOwnerMaxRefreshAmount:
cfg.BoxOwnerMaxRefreshAmountSeconds = value
case SettingBoxOwnerMaxTotalExpiry:
cfg.BoxOwnerMaxTotalExpirySeconds = value
}
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
case SettingThumbnailIntervalSeconds:
cfg.ThumbnailIntervalSeconds = value
case SettingBoxOwnerMaxRefreshCount:
cfg.BoxOwnerMaxRefreshCount = value
}
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
import "time"
import (
"encoding/json"
"time"
)
const AdminTagName = "admin"
@@ -74,3 +77,78 @@ type BootstrapResult struct {
AdminUser *User
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 {
Files []BoxFile `json:"files"`
OwnerID string `json:"owner_id,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
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")
if token == "" {
token = ctx.GetHeader("X-CSRF-Token")
}
return token != "" && subtleConstantTimeEqual(token, session.CSRFToken)
}

View File

@@ -1,18 +1,28 @@
package server
import "github.com/gin-gonic/gin"
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) registerAdminRoutes(router *gin.Engine) {
admin := router.Group("/admin")
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.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.Use(app.requireAdminSession)
protected.POST("/logout", app.handleAdminLogout)
protected.GET("", app.handleAdminDashboard)
protected.GET("/", app.handleAdminDashboard)
protected.GET("/boxes", app.handleAdminBoxes)
protected.GET("/users", app.handleAdminUsers)
protected.POST("/users", app.handleAdminUsersPost)

View File

@@ -74,6 +74,7 @@ func Run(addr string) error {
DirectBoxUpload: app.handleDirectBoxUpload,
LegacyUpload: app.handleLegacyUpload,
})
app.registerAccountRoutes(router)
app.registerAdminRoutes(router)
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()})
return
}
app.indexBoxFromManifest(boxID)
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()})
return
}
app.indexBoxFromManifest(boxID)
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()})
return
}
app.indexBoxFromManifest(boxID)
ctx.JSON(http.StatusOK, gin.H{"file": file})
}
@@ -231,6 +234,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
savedFiles = append(savedFiles, savedFile)
}
app.indexBoxFromManifest(boxID)
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" . }}