feat(models): add alert and box models
Adds comprehensive data structures for Alert and Box functionality across models.
This commit is contained in:
247
lib/metastore/alerts.go
Normal file
247
lib/metastore/alerts.go
Normal 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))
|
||||
}
|
||||
89
lib/metastore/alerts_test.go
Normal file
89
lib/metastore/alerts_test.go
Normal 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
188
lib/metastore/boxes.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
386
lib/server/account_alerts.go
Normal file
386
lib/server/account_alerts.go
Normal 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
|
||||
}
|
||||
155
lib/server/account_alerts_test.go
Normal file
155
lib/server/account_alerts_test.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
454
lib/server/account_boxes.go
Normal file
454
lib/server/account_boxes.go
Normal 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
|
||||
}
|
||||
220
lib/server/account_boxes_test.go
Normal file
220
lib/server/account_boxes_test.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
static/js/account-alerts.js
Normal file
16
static/js/account-alerts.js
Normal 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 || "{}";
|
||||
});
|
||||
});
|
||||
});
|
||||
182
templates/account_alerts.html
Normal file
182
templates/account_alerts.html
Normal 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" . }}
|
||||
174
templates/account_boxes.html
Normal file
174
templates/account_boxes.html
Normal 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" . }}
|
||||
Reference in New Issue
Block a user