From e1038298702c56a01a892a0cb6b4eec4e3e8654e Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Thu, 30 Apr 2026 19:45:22 +0300 Subject: [PATCH] feat(models): add alert and box models Adds comprehensive data structures for Alert and Box functionality across models. --- lib/metastore/alerts.go | 247 ++++++++++++++++ lib/metastore/alerts_test.go | 89 ++++++ lib/metastore/boxes.go | 188 +++++++++++++ lib/metastore/models.go | 80 +++++- lib/models/models.go | 2 + lib/server/account_alerts.go | 386 +++++++++++++++++++++++++ lib/server/account_alerts_test.go | 155 ++++++++++ lib/server/account_auth.go | 12 + lib/server/account_boxes.go | 454 ++++++++++++++++++++++++++++++ lib/server/account_boxes_test.go | 220 +++++++++++++++ lib/server/account_pages.go | 31 +- lib/server/uploads.go | 4 + static/css/account.css | 129 ++++++++- static/js/account-alerts.js | 16 ++ templates/account_alerts.html | 182 ++++++++++++ templates/account_boxes.html | 174 ++++++++++++ 16 files changed, 2359 insertions(+), 10 deletions(-) create mode 100644 lib/metastore/alerts.go create mode 100644 lib/metastore/alerts_test.go create mode 100644 lib/metastore/boxes.go create mode 100644 lib/server/account_alerts.go create mode 100644 lib/server/account_alerts_test.go create mode 100644 lib/server/account_boxes.go create mode 100644 lib/server/account_boxes_test.go create mode 100644 static/js/account-alerts.js create mode 100644 templates/account_alerts.html create mode 100644 templates/account_boxes.html diff --git a/lib/metastore/alerts.go b/lib/metastore/alerts.go new file mode 100644 index 0000000..013f665 --- /dev/null +++ b/lib/metastore/alerts.go @@ -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)) +} diff --git a/lib/metastore/alerts_test.go b/lib/metastore/alerts_test.go new file mode 100644 index 0000000..3b9a82f --- /dev/null +++ b/lib/metastore/alerts_test.go @@ -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") + } +} diff --git a/lib/metastore/boxes.go b/lib/metastore/boxes.go new file mode 100644 index 0000000..42eb7f9 --- /dev/null +++ b/lib/metastore/boxes.go @@ -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 +} diff --git a/lib/metastore/models.go b/lib/metastore/models.go index 1b39098..0deb837 100644 --- a/lib/metastore/models.go +++ b/lib/metastore/models.go @@ -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 +} diff --git a/lib/models/models.go b/lib/models/models.go index 4e2424f..e5c9291 100644 --- a/lib/models/models.go +++ b/lib/models/models.go @@ -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"` diff --git a/lib/server/account_alerts.go b/lib/server/account_alerts.go new file mode 100644 index 0000000..6ca607e --- /dev/null +++ b/lib/server/account_alerts.go @@ -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 +} diff --git a/lib/server/account_alerts_test.go b/lib/server/account_alerts_test.go new file mode 100644 index 0000000..cd584b2 --- /dev/null +++ b/lib/server/account_alerts_test.go @@ -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 +} diff --git a/lib/server/account_auth.go b/lib/server/account_auth.go index ad08920..275fe0c 100644 --- a/lib/server/account_auth.go +++ b/lib/server/account_auth.go @@ -28,6 +28,18 @@ func (app *App) registerAccountRoutes(router *gin.Engine) { 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) { diff --git a/lib/server/account_boxes.go b/lib/server/account_boxes.go new file mode 100644 index 0000000..2d99a8f --- /dev/null +++ b/lib/server/account_boxes.go @@ -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 +} diff --git a/lib/server/account_boxes_test.go b/lib/server/account_boxes_test.go new file mode 100644 index 0000000..bf7d41c --- /dev/null +++ b/lib/server/account_boxes_test.go @@ -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 +} diff --git a/lib/server/account_pages.go b/lib/server/account_pages.go index f6d94cc..b03cc25 100644 --- a/lib/server/account_pages.go +++ b/lib/server/account_pages.go @@ -119,6 +119,12 @@ func (app *App) GetAccountDashboard(ctx *gin.Context, actor metastore.User) (Acc 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 { @@ -144,7 +150,7 @@ func (app *App) GetAccountDashboard(ctx *gin.Context, actor metastore.User) (Acc CSRFToken: app.currentCSRFToken(ctx), Stats: stats, Statuses: app.accountDashboardStatuses(), - Alerts: accountPlaceholderAlerts(), + Alerts: alertPreview, RecentBoxes: recentBoxes, RecentActivity: accountPlaceholderActivity(actor, ctx), ShowUsersStat: showUsersStat, @@ -164,14 +170,23 @@ func (app *App) accountDashboardStatuses() []accountStatusRow { } } -func accountPlaceholderAlerts() []accountAlertPreviewRow { - return []accountAlertPreviewRow{ - { - Severity: "info", - Title: "Alerts system pending", - Detail: "Dedicated alert storage arrives in the alerts implementation pass.", - }, +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 { diff --git a/lib/server/uploads.go b/lib/server/uploads.go index 33f1b2e..80ef90b 100644 --- a/lib/server/uploads.go +++ b/lib/server/uploads.go @@ -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}) } diff --git a/static/css/account.css b/static/css/account.css index da0984b..fdd2fa9 100644 --- a/static/css/account.css +++ b/static/css/account.css @@ -938,6 +938,108 @@ textarea:disabled { margin: 0; } +.alerts-layout { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 10px; + min-height: min(920px, calc(100vh - 96px)); +} + +.alerts-filterbar { + display: grid; + grid-template-columns: minmax(180px, 1fr) repeat(4, minmax(130px, auto)) auto; + align-items: end; + gap: 8px; + padding: 8px; +} + +.alerts-workspace { + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(320px, .55fr); + gap: 10px; +} + +.alerts-table-scroll { + height: 520px; +} + +.alerts-table { + min-width: 980px; +} + +.alerts-table tr.is-selected td { + background: #ffffcc; +} + +.alerts-detail { + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 8px; + padding: 8px; +} + +.alerts-detail h2, +.alerts-detail p { + margin: 0; +} + +.metadata-pre { + min-height: 260px; + margin: 0; + padding: 10px; + overflow: auto; + color: #b7ffc8; + background: #030403; + background-image: repeating-linear-gradient(transparent 0 4px, rgba(0, 255, 102, .018) 4px 6px); + font-family: 'MonoCraft', 'Courier New', monospace; + font-size: 12px; + line-height: 16px; + white-space: pre-wrap; +} + +.bulk-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + padding: 8px; +} + +.boxes-layout { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 10px; + min-height: min(920px, calc(100vh - 96px)); +} + +.boxes-filterbar { + display: grid; + grid-template-columns: minmax(180px, 1fr) repeat(4, minmax(120px, auto)) auto; + align-items: end; + gap: 8px; + padding: 8px; +} + +.boxes-table-scroll { + height: 540px; + overflow-x: auto; +} + +.boxes-table { + min-width: 1180px; +} + +.pagination-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 8px; +} + .scroll-panel { overflow: auto; color: #000000; @@ -1214,10 +1316,19 @@ textarea:disabled { } .dashboard-hero, - .main-grid { + .main-grid, + .alerts-workspace { grid-template-columns: 1fr; } + .alerts-filterbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .boxes-filterbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .span-2 { grid-column: auto; } @@ -1333,6 +1444,22 @@ textarea:disabled { grid-template-columns: 58px minmax(0, 1fr); } + .alerts-filterbar { + grid-template-columns: 1fr; + } + + .alerts-table-scroll { + height: 420px; + } + + .boxes-filterbar { + grid-template-columns: 1fr; + } + + .boxes-table-scroll { + height: 420px; + } + .win98-window-controls { display: none; } diff --git a/static/js/account-alerts.js b/static/js/account-alerts.js new file mode 100644 index 0000000..6067256 --- /dev/null +++ b/static/js/account-alerts.js @@ -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 || "{}"; + }); + }); +}); diff --git a/templates/account_alerts.html b/templates/account_alerts.html new file mode 100644 index 0000000..4c6261c --- /dev/null +++ b/templates/account_alerts.html @@ -0,0 +1,182 @@ +{{ template "account_shell_start" . }} +
+ {{ template "account_window_titlebar" . }} + + + + + +
+ alerts + {{ .Stats.Open }} open + ready +
+
+{{ template "account_shell_end" . }} diff --git a/templates/account_boxes.html b/templates/account_boxes.html new file mode 100644 index 0000000..9f4f6b6 --- /dev/null +++ b/templates/account_boxes.html @@ -0,0 +1,174 @@ +{{ template "account_shell_start" . }} +
+ {{ template "account_window_titlebar" . }} + + + + + + +
+{{ template "account_shell_end" . }}