From b8bb75f7e019417eea4b41e14155c01967d4fb72 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Thu, 30 Apr 2026 12:46:44 +0300 Subject: [PATCH] feat(cli): add robust box listing filters and sorting --- .gitignore | 1 + admin-overview.md | 710 ---------------------------------------------- cmd/cmd_box.go | 192 ++++++++++++- cmd/cmd_format.go | 101 +++++++ 4 files changed, 291 insertions(+), 713 deletions(-) delete mode 100644 admin-overview.md diff --git a/.gitignore b/.gitignore index c3e1290..90daf19 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ data/ .env docker-compose.yml +dev # Go bin/ diff --git a/admin-overview.md b/admin-overview.md deleted file mode 100644 index fb9c809..0000000 --- a/admin-overview.md +++ /dev/null @@ -1,710 +0,0 @@ -# WarpBox Admin Overview - -This document maps the current WarpBox admin area, explains how the existing pages and permission model work, then proposes an overhaul path for a more useful admin product: dashboard, account management, API keys, and a role/group based authorization system. - -## Project Context - -WarpBox is a small self-hosted file sharing app. Its core product is deliberately simple: - -- create temporary upload boxes -- upload one or more files -- optionally protect the box with a password -- share a generated link -- allow individual downloads or ZIP downloads -- support one-time ZIP handoff -- store uploads on the local filesystem -- store app metadata in local BadgerDB - -The project has a strong retro desktop identity. The admin area should keep that personality, but become more operationally useful. Best direction: retro surface, modern information architecture. Admin actions should feel clear, forgiving, and inspectable rather than dense or mysterious. - -## Current Admin Architecture - -Admin routes are registered under `/admin` in `lib/server/admin.go`. - -Current pages: - -- `/admin/login` -- `/admin` -- `/admin/boxes` -- `/admin/users` -- `/admin/tags` -- `/admin/settings` - -Admin templates live in: - -- `templates/admin_login.html` -- `templates/admin.html` -- `templates/admin_boxes.html` -- `templates/admin_users.html` -- `templates/admin_tags.html` -- `templates/admin_settings.html` - -Styling uses: - -- `static/css/admin.css` -- shared retro UI styles from `static/css/app.css` and `static/css/window.css` - -Metadata lives in BadgerDB through `lib/metastore`. Current admin-relevant records are: - -- users -- tags -- sessions -- settings overrides - -## Current Login And Session Flow - -Admin login is bootstrapped from environment configuration: - -- `WARPBOX_ADMIN_PASSWORD` -- `WARPBOX_ADMIN_USERNAME` -- `WARPBOX_ADMIN_EMAIL` -- `WARPBOX_ADMIN_ENABLED` - -On startup, `BootstrapAdmin` ensures a protected `admin` tag exists. If a user matching the admin username already exists, the admin tag is attached to that user. If no user exists and `WARPBOX_ADMIN_PASSWORD` is set, a first admin user is created. - -Login behavior: - -- `GET /admin/login` shows login form when admin login is enabled. -- `POST /admin/login` checks username/password. -- password hashes use bcrypt. -- disabled users cannot log in. -- user must have effective `AdminAccess`. -- successful login creates a BadgerDB session with random session token and CSRF token. -- session cookie name is `warpbox_admin_session`. -- cookie path is `/admin`. -- cookie is HTTP-only. -- cookie Secure flag is controlled by `WARPBOX_ADMIN_COOKIE_SECURE`. -- session TTL is controlled by `WARPBOX_SESSION_TTL_SECONDS`. - -All protected admin routes require: - -- valid session cookie -- unexpired session -- valid CSRF token for non-GET requests -- existing enabled user -- effective `AdminAccess` - -Admin responses set no-store headers and `X-Content-Type-Options: nosniff`. - -## Current Dashboard Page - -Route: - -- `GET /admin` - -Template: - -- `templates/admin.html` - -Current behavior: - -- shows signed-in username -- provides logout button -- shows four large links: - - Boxes - - Users - - Tags - - Settings - -Current limitation: - -- this is not a dashboard yet. It has no statistics, recent activity, health status, quota information, user counts, storage totals, warnings, or next actions. - -Good future role: - -- make this the admin home screen, with live operational summary and clear links into deeper management pages. - -## Current Boxes Page - -Route: - -- `GET /admin/boxes` - -Required permission: - -- `AdminBoxesView` - -Current behavior: - -- loads all box summaries from filesystem via `boxstore.ListBoxSummaries()` -- shows summary counters: - - total boxes - - total storage - - expired boxes -- table columns: - - box id - - file count - - size - - created time - - expiry time - - flags -- flags include: - - expired - - one-time - - password -- box id links to `/box/{id}` - -Current limitations: - -- no search -- no filters -- no pagination -- no delete action -- no bulk cleanup -- no storage trend -- no per-box detail drawer -- no visibility into failed uploads, incomplete boxes, thumbnail state, or one-time consumption state -- expired boxes are counted but not directly actionable - -Good future role: - -- operational file-sharing monitor and cleanup center. - -## Current Users Page - -Routes: - -- `GET /admin/users` -- `POST /admin/users` - -Required permission: - -- `AdminUsersManage` - -Current behavior: - -- create user with username, email, password, and selected tags -- list users sorted by username -- show username, email, assigned tags, created time, status -- enable/disable user -- active session user cannot disable themselves - -Current user model: - -- `ID` -- `Username` -- `Email` -- `PasswordHash` -- `TagIDs` -- `CreatedAt` -- `UpdatedAt` -- `Disabled` -- optional per-user max file size -- optional per-user max box size -- optional per-user max expiry seconds - -Current limitations: - -- no edit user page -- no password reset flow -- no email verification or invite flow -- no delete/archive user action -- no user detail screen -- no role preview -- no effective permissions display -- no API key management -- no account activity -- no per-user storage/upload stats -- per-user limit fields exist in the model but are not exposed in the admin UI - -Good future role: - -- user account command center, optimized for quick scanning, safe edits, and permission clarity. - -## Current Tags Page - -Routes: - -- `GET /admin/tags` -- `POST /admin/tags` - -Required permission: - -- `AdminUsersManage` - -Current behavior: - -- create a tag -- set description -- attach permission booleans -- attach optional limits -- list existing tags -- built-in `admin` tag is protected and forced to full admin permissions - -Current tag permissions: - -- upload allowed -- one-time download allowed -- ZIP download allowed -- renewable allowed -- admin access -- manage users -- manage settings -- view boxes -- max file size bytes -- max box size bytes -- allowed expiry seconds -- renew on access seconds exists in model but is not exposed in current tag form -- renew on download seconds exists in model but is not exposed in current tag form - -Permission resolution: - -- user permissions are resolved from all assigned tags plus user-level overrides -- boolean permissions are additive: any tag can grant a permission -- size limits use the more permissive value -- global max file/box limits still cap resolved user limits -- allowed expiry seconds are merged from all tags and sorted -- global feature flags can still disable ZIP or one-time downloads even if a tag allows them - -Current limitations: - -- tags mix labels, roles, groups, limits, and admin capability grants in one concept -- no edit tag page -- no delete tag action -- no user count per tag -- no permission preview -- no conflict detection -- no clear distinction between "role grants access" and "plan defines quotas" -- permission logic is powerful but hard to explain to administrators - -Good future role: - -- replace tags with explicit roles/groups/plans. Keep old tags only as migration input. - -## Current Settings Page - -Routes: - -- `GET /admin/settings` -- `POST /admin/settings` - -Required permission: - -- `AdminSettingsManage` - -Current behavior: - -- shows configured settings table -- columns: - - setting - - value - - source - - environment variable name -- editable values can be overridden from admin UI when `WARPBOX_ALLOW_ADMIN_SETTINGS_OVERRIDE=true` -- overrides are stored in BadgerDB -- runtime config is updated after save - -Editable settings currently include: - -- guest uploads enabled -- API enabled -- ZIP downloads enabled -- one-time downloads enabled -- one-time download expiry seconds -- renew on access enabled -- renew on download enabled -- default guest expiry seconds -- max guest expiry seconds -- default user max file size bytes -- default user max box size bytes -- session TTL seconds -- box poll interval milliseconds -- thumbnail batch size -- thumbnail interval seconds - -Non-editable/hard settings include: - -- data directory -- global max file size bytes -- global max box size bytes -- one-time download retry on failure - -Current limitations: - -- settings are presented as raw technical rows -- no grouping -- no descriptions -- no validation hints beyond backend errors -- byte fields require raw bytes -- duration fields require raw seconds/milliseconds -- no "restart required" or "runtime applied" distinction beyond current editability -- no audit trail for setting changes - -Good future role: - -- organized system configuration, with human units, clear safety boundaries, and change history. - -## Current API Key State - -There is visible API key UX in the upload page: - -- "Use API key for larger quota" -- API key input -- local validation with regex -- value saved locally in browser localStorage -- cURL command includes `Authorization: Bearer YOUR_API_KEY` when enabled - -Important current reality: - -- no backend API key model was found -- no API key generation route was found -- no server-side Authorization bearer validation was found -- no per-user API key ownership or revocation exists yet -- no current upload flow applies user permissions from an API key - -So API keys are a product placeholder, not a functional feature yet. - -## Main Current Gaps - -- Dashboard is only navigation. -- User management only creates and disables accounts. -- Tags are doing too many jobs. -- API keys are UI-only. -- Admin pages lack search, filters, pagination, and detail views. -- No audit log. -- No admin-visible system health. -- No storage cleanup controls. -- No account self-service area for non-admin users. -- Existing permission model is additive and permissive, which can surprise admins. - -## Recommended Overhaul Direction - -Build a full admin solution around four clear concepts: - -- Dashboard: current health and useful actions. -- Accounts: people who can sign in or use API keys. -- Roles/Groups: permission bundles and admin capabilities. -- Plans/Limits: quotas and upload policy. - -This separates identity from authorization from quotas. It should make the system easier to explain and safer to operate. - -## Proposed Dashboard - -Recommended dashboard cards: - -- total active boxes -- total storage used -- expired boxes waiting cleanup -- boxes created today / last 24 hours -- uploads completed today / last 24 hours -- failed or incomplete uploads -- active users -- disabled users -- API keys active -- admin sessions active -- thumbnail queue / worker state -- current global limits -- guest uploads status -- ZIP downloads status -- one-time downloads status - -Recommended dashboard sections: - -- "Needs attention" - - expired boxes - - failed uploads - - one-time boxes stuck incomplete - - high storage usage - - disabled API setting while API key UI is visible -- "Recent boxes" - - latest boxes with flags and size -- "Recent admin activity" - - user created, role changed, setting changed, API key revoked -- "System" - - data directory - - BadgerDB status - - uploads directory size - - thumbnail worker timing - - config source summary - -UX idea: - -- dashboard should answer "is WarpBox healthy?", "what changed recently?", and "what should I do next?" in one glance. - -## Proposed Account Management - -Recommended account list: - -- search by username/email -- filter by status, role/group, plan, API key presence -- columns: - - user - - email - - status - - roles/groups - - plan/limits - - API keys - - created - - last login / last API use - - storage used - - boxes created - -Recommended account detail page: - -- profile -- status controls -- password reset / force password change -- roles/groups assignment -- plan/limits assignment -- effective permissions preview -- API keys tab -- recent activity tab -- boxes owned by user - -Recommended safe actions: - -- disable user -- revoke all sessions -- revoke all API keys -- reset password -- assign role/group -- assign plan -- archive user - -Recommended UX details: - -- show effective permission summary before save -- warn when removing own admin access -- require confirmation for disabling final admin -- prevent accidental lockout at backend level -- show inherited vs direct settings clearly - -## Proposed API Key System - -Data model idea: - -- API key id -- user id -- name/label -- hashed secret -- secret prefix for display -- scopes -- created at -- expires at -- last used at -- revoked at -- created by -- optional allowed IP/CIDR list - -Security rules: - -- show raw key only once on creation -- store only hash server-side -- allow revoke, rotate, rename -- support expiry dates -- log last-used timestamp -- rate limit failed key attempts -- avoid putting API keys in URLs - -User-facing API key page: - -- create key -- name key -- choose expiry -- view active/revoked keys -- revoke key -- copy cURL example -- see last used time - -Admin-facing API key controls: - -- view user key count and last use -- revoke a user key -- revoke all keys for disabled user -- optionally create key on behalf of user -- audit key creation/revocation - -Permission behavior: - -- bearer key resolves to user -- user roles/groups/plans determine upload policy -- key scopes can further restrict user permission but not exceed it -- API key can enable higher quota only if assigned user's plan allows it - -Initial scopes: - -- `box:create` -- `box:upload` -- `box:read` -- `box:download` -- `box:delete-own` - -## Proposed Role/Group System - -Replace tags with explicit authorization objects. - -Recommended models: - -- Role: permission bundle, e.g. `admin`, `operator`, `uploader`, `viewer` -- Group: collection of users with assigned roles and optional plan -- Plan: quota/limit policy, e.g. `guest`, `standard`, `trusted`, `unlimited` - -Roles should answer: - -- what can this user do? - -Plans should answer: - -- how much can this user use? - -Groups should answer: - -- who receives these defaults together? - -Recommended permissions: - -- admin.access -- admin.dashboard.view -- admin.users.view -- admin.users.manage -- admin.roles.manage -- admin.settings.view -- admin.settings.manage -- admin.boxes.view -- admin.boxes.manage -- boxes.create -- boxes.download.zip -- boxes.download.one_time -- boxes.password.set -- boxes.renew -- api_keys.manage_own -- api_keys.manage_any - -Recommended limit fields: - -- max file size -- max box size -- max boxes per day -- max storage active at once -- max expiry -- allowed expiry choices -- max API keys -- API key max TTL -- guest upload allowed -- ZIP allowed -- one-time allowed -- renew allowed - -Recommended resolver: - -- start with system defaults -- apply assigned plan quotas -- apply group plan when user has no direct plan -- apply direct user overrides last -- roles grant permissions -- scopes restrict API key actions -- hard global limits cap everything - -Migration strategy: - -- create role equivalents from current tag admin booleans -- create plan equivalents from current tag upload limits -- assign users based on existing `TagIDs` -- keep read-only legacy tag view during migration -- remove tag creation from final UI - -## Extra Feature Ideas - -Storage and cleanup: - -- expired box cleanup page -- bulk delete expired boxes -- storage by age chart -- largest boxes list -- orphaned manifest/file scanner -- thumbnail cleanup/rebuild tools - -Security: - -- audit log -- active admin sessions page -- revoke sessions -- failed login tracking -- optional two-factor auth for admins -- final-admin protection -- configurable password policy - -Operations: - -- system health page -- config export -- backup/restore notes for data directory and BadgerDB -- maintenance mode -- manual thumbnail worker run -- background job status - -User experience: - -- account self-service page -- personal upload history -- personal quota meter -- personal API keys -- upload presets -- saved retention preferences - -Admin UX: - -- global search -- command palette -- filters saved per admin -- CSV export for users/boxes -- inline detail drawer for boxes/users -- change preview before saving roles/plans - -Product: - -- named boxes supported server-side -- custom slug support server-side -- private/listed boxes if public listing is added -- max view count server-side -- ownership model: boxes created by user/API key belong to that user -- public share page controls based on owner plan - -## Suggested Implementation Phases - -Phase 1: make current admin useful - -- add real dashboard statistics -- add search/filter/pagination to boxes and users -- expose effective permissions on user rows/details -- add user edit form -- add storage cleanup actions - -Phase 2: implement API keys - -- add API key model in BadgerDB -- add create/revoke/list routes -- hash keys server-side -- validate bearer keys on API endpoints -- connect key to user permissions -- add self-service API key UI - -Phase 3: replace tags - -- add roles/plans/groups models -- add resolver -- add migration from tags -- update user management UI -- deprecate tag creation - -Phase 4: polish into full admin solution - -- audit log -- account detail pages -- system health -- advanced cleanup -- activity timeline -- safer setting editor - -## Product Principle For The Overhaul - -Keep WarpBox small, local, and understandable. The admin area should not become enterprise software cosplay. It should give the operator sharp tools: - -- see what is happening -- fix common problems -- manage people safely -- give trusted users more power -- keep storage under control -- make permission decisions obvious - -Best version: a retro control panel that behaves like a modern, careful admin console. diff --git a/cmd/cmd_box.go b/cmd/cmd_box.go index 1c5c091..6ed9b51 100644 --- a/cmd/cmd_box.go +++ b/cmd/cmd_box.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "os" + "sort" + "strconv" "strings" "time" @@ -36,10 +38,20 @@ func newBoxCommand() *cobra.Command { func newBoxListCommand() *cobra.Command { var format string var uploadRoot string + var sortBy string + var sortOrder string + var filterExpired string + var filterPassword string + var filterOneTime string + var filterSizeMin string + var filterSizeMax string + var filterCreatedAfter string + var filterCreatedBefore string cmd := &cobra.Command{ Use: "ls", Aliases: []string{"list", "view"}, Short: "List all boxes", + Long: "List all boxes with optional sorting and filtering.", RunE: func(cmd *cobra.Command, args []string) error { if uploadRoot != "" { boxstore.SetUploadRoot(uploadRoot) @@ -52,6 +64,18 @@ func newBoxListCommand() *cobra.Command { fmt.Println("No boxes found.") return nil } + + // Apply filters + summaries = filterBoxes(summaries, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore) + + if len(summaries) == 0 { + fmt.Println("No boxes match the given filters.") + return nil + } + + // Apply sorting + sortBoxes(summaries, sortBy, sortOrder) + switch format { case "json": return formatBoxSummariesJSON(summaries) @@ -64,11 +88,140 @@ func newBoxListCommand() *cobra.Command { } cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json") cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory") + cmd.Flags().StringVar(&sortBy, "sort", "created", "Sort field: created, expires, size, files") + cmd.Flags().StringVar(&sortOrder, "sort-order", "desc", "Sort order: asc, desc") + cmd.Flags().StringVar(&filterExpired, "filter-expired", "", "Filter by expiry: yes, no, all") + cmd.Flags().StringVar(&filterPassword, "filter-password", "", "Filter by password: yes, no, all") + cmd.Flags().StringVar(&filterOneTime, "filter-one-time", "", "Filter by one-time: yes, no, all") + cmd.Flags().StringVar(&filterSizeMin, "filter-size-min", "", "Minimum total size in bytes (e.g. 1024, 1k, 1m, 1g)") + cmd.Flags().StringVar(&filterSizeMax, "filter-size-max", "", "Maximum total size in bytes (e.g. 1024, 1k, 1m, 1g)") + cmd.Flags().StringVar(&filterCreatedAfter, "filter-created-after", "", "Only boxes created after this time (RFC3339)") + cmd.Flags().StringVar(&filterCreatedBefore, "filter-created-before", "", "Only boxes created before this time (RFC3339)") return cmd } +func filterBoxes(summaries []models.BoxSummary, filterExpired, filterPassword, filterOneTime, filterSizeMin, filterSizeMax, filterCreatedAfter, filterCreatedBefore string) []models.BoxSummary { + result := make([]models.BoxSummary, 0, len(summaries)) + + minSize, _ := parseSizeFilter(filterSizeMin) + maxSize, _ := parseSizeFilter(filterSizeMax) + createdAfter, _ := time.Parse(time.RFC3339, filterCreatedAfter) + createdBefore, _ := time.Parse(time.RFC3339, filterCreatedBefore) + + for _, s := range summaries { + if filterExpired != "" && filterExpired != "all" { + match := "no" + if s.Expired { + match = "yes" + } + if match != filterExpired { + continue + } + } + if filterPassword != "" && filterPassword != "all" { + match := "no" + if s.PasswordProtected { + match = "yes" + } + if match != filterPassword { + continue + } + } + if filterOneTime != "" && filterOneTime != "all" { + match := "no" + if s.OneTimeDownload { + match = "yes" + } + if match != filterOneTime { + continue + } + } + if minSize > 0 && s.TotalSize < minSize { + continue + } + if maxSize > 0 && s.TotalSize > maxSize { + continue + } + if !createdAfter.IsZero() && s.CreatedAt.Before(createdAfter) { + continue + } + if !createdBefore.IsZero() && !s.CreatedAt.Before(createdBefore) { + continue + } + result = append(result, s) + } + return result +} + +func parseSizeFilter(s string) (int64, error) { + if s == "" { + return 0, nil + } + s = strings.TrimSpace(s) + lower := strings.ToLower(s) + + multiplier := int64(1) + switch { + case strings.HasSuffix(lower, "g"): + multiplier = 1024 * 1024 * 1024 + s = strings.TrimSuffix(lower, "g") + case strings.HasSuffix(lower, "m"): + multiplier = 1024 * 1024 + s = strings.TrimSuffix(lower, "m") + case strings.HasSuffix(lower, "k"): + multiplier = 1024 + s = strings.TrimSuffix(lower, "k") + } + + val, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid size filter: %s", s) + } + return val * multiplier, nil +} + +func sortBoxes(summaries []models.BoxSummary, sortBy, sortOrder string) { + reverse := false + if strings.EqualFold(sortOrder, "desc") { + reverse = true + } + + sort.SliceStable(summaries, func(i, j int) bool { + var less bool + switch strings.ToLower(sortBy) { + case "size": + less = summaries[i].TotalSize < summaries[j].TotalSize + case "files": + less = summaries[i].FileCount < summaries[j].FileCount + case "expires": + // Boxes with no expiry go last + iZero := summaries[i].ExpiresAt.IsZero() + jZero := summaries[j].ExpiresAt.IsZero() + if iZero && jZero { + return false + } + if iZero { + return false + } + if jZero { + return true + } + less = summaries[i].ExpiresAt.Before(summaries[j].ExpiresAt) + case "created", "": + less = summaries[i].CreatedAt.Before(summaries[j].CreatedAt) + default: + less = summaries[i].ID < summaries[j].ID + } + if reverse { + return !less + } + return less + }) +} + func newBoxViewCommand() *cobra.Command { var uploadRoot string + var asJSON bool cmd := &cobra.Command{ Use: "view", Short: "View box summary", @@ -83,17 +236,22 @@ func newBoxViewCommand() *cobra.Command { if err != nil { return fmt.Errorf("failed to view box %s: %w", boxID, err) } + if asJSON { + return formatBoxSummaryJSON(&summary) + } printBoxSummary(&summary) return nil }, } cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON") return cmd } func newBoxInspectCommand() *cobra.Command { var uploadRoot string var full bool + var asJSON bool cmd := &cobra.Command{ Use: "inspect", Short: "Inspect box manifest (raw JSON)", @@ -122,12 +280,15 @@ func newBoxInspectCommand() *cobra.Command { } cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory") cmd.Flags().BoolVar(&full, "full", false, "Show sensitive fields (password hash, auth token)") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON (default for inspect)") + _ = asJSON // inspect is always JSON; flag kept for consistency return cmd } func newBoxDeleteCommand() *cobra.Command { var uploadRoot string var force bool + var asJSON bool cmd := &cobra.Command{ Use: "rm", Aliases: []string{"del", "delete"}, @@ -146,19 +307,33 @@ func newBoxDeleteCommand() *cobra.Command { confirm = "n" } if strings.ToLower(strings.TrimSpace(confirm)) != "y" { - fmt.Println("Aborted.") + if asJSON { + fmt.Println(`{"deleted": false, "reason": "aborted"}`) + } else { + fmt.Println("Aborted.") + } return nil } } if err := boxstore.DeleteBox(boxID); err != nil { - return fmt.Errorf("failed to delete box %s: %w", boxID, err) + if asJSON { + fmt.Printf(`{"deleted": false, "error": "%s"}\n`, strings.ReplaceAll(err.Error(), `"`, `\"`)) + } else { + return fmt.Errorf("failed to delete box %s: %w", boxID, err) + } + return nil + } + if asJSON { + fmt.Printf(`{"deleted": true, "box_id": "%s"}\n`, boxID) + } else { + fmt.Printf("Box %s deleted.\n", boxID) } - fmt.Printf("Box %s deleted.\n", boxID) return nil }, } cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory") cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON") return cmd } @@ -171,6 +346,7 @@ func newBoxChangeCommand() *cobra.Command { var oneTime bool var renew bool var renewSeconds int64 + var asJSON bool cmd := &cobra.Command{ Use: "change", @@ -214,6 +390,9 @@ func newBoxChangeCommand() *cobra.Command { return fmt.Errorf("failed to save manifest for box %s: %w", boxID, err) } + if asJSON { + return formatChangeResultJSON(boxID, manifest) + } fmt.Printf("Box %s updated.\n", boxID) return nil }, @@ -227,6 +406,7 @@ func newBoxChangeCommand() *cobra.Command { cmd.Flags().BoolVar(&oneTime, "one-time", false, "Enable one-time download mode") cmd.Flags().BoolVar(&renew, "renew", false, "Renew box expiry (use --renew-seconds for duration)") cmd.Flags().Int64Var(&renewSeconds, "renew-seconds", 0, "Seconds to extend expiry by (used with --renew)") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON") return cmd } @@ -332,6 +512,7 @@ func renewBoxExpiry(m *models.BoxManifest, seconds int64) error { func newBoxGetCommand() *cobra.Command { var uploadRoot string + var asJSON bool cmd := &cobra.Command{ Use: "get", Short: "Get box URL and info", @@ -346,6 +527,10 @@ func newBoxGetCommand() *cobra.Command { return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err) } + if asJSON { + return formatBoxGetJSON(boxID, manifest) + } + fmt.Printf("Box ID:\t%s\n", boxID) fmt.Printf("URL:\t/box/%s\n", boxID) if !manifest.CreatedAt.IsZero() { @@ -364,5 +549,6 @@ func newBoxGetCommand() *cobra.Command { }, } cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON") return cmd } diff --git a/cmd/cmd_format.go b/cmd/cmd_format.go index 8158f73..9673640 100644 --- a/cmd/cmd_format.go +++ b/cmd/cmd_format.go @@ -11,6 +11,8 @@ import ( "warpbox/lib/models" ) +// ── List output ────────────────────────────────────────────── + func formatBoxSummariesTable(summaries []models.BoxSummary) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tFiles\tSize\tCreated\tExpires\tPassword\tOne-Time\tExpired") @@ -53,6 +55,31 @@ func formatBoxSummariesJSON(summaries []models.BoxSummary) error { return enc.Encode(out) } +// ── View output ────────────────────────────────────────────── + +func formatBoxSummaryJSON(s *models.BoxSummary) error { + type summaryOut struct { + ID string `json:"id"` + FileCount int `json:"file_count"` + TotalSize int64 `json:"total_size"` + TotalSizeLabel string `json:"total_size_label"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + Expired bool `json:"expired"` + OneTimeDownload bool `json:"one_time_download"` + PasswordProtected bool `json:"password_protected"` + } + out := summaryOut{ + ID: s.ID, FileCount: s.FileCount, TotalSize: s.TotalSize, + TotalSizeLabel: s.TotalSizeLabel, CreatedAt: s.CreatedAt, + ExpiresAt: s.ExpiresAt, Expired: s.Expired, + OneTimeDownload: s.OneTimeDownload, PasswordProtected: s.PasswordProtected, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} + func printBoxSummary(s *models.BoxSummary) { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "ID:\t%s\n", s.ID) @@ -70,6 +97,80 @@ func printBoxSummary(s *models.BoxSummary) { w.Flush() } +// ── Get output ─────────────────────────────────────────────── + +func formatBoxGetJSON(boxID string, manifest models.BoxManifest) error { + type getOut struct { + BoxID string `json:"box_id"` + URL string `json:"url"` + CreatedAt time.Time `json:"created_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Expired bool `json:"expired"` + PasswordProtected bool `json:"password_protected"` + OneTimeDownload bool `json:"one_time_download"` + RetentionKey string `json:"retention_key,omitempty"` + RetentionLabel string `json:"retention_label,omitempty"` + } + out := getOut{ + BoxID: boxID, URL: "/box/" + boxID, + Expired: boxstore.IsExpired(manifest), + } + if !manifest.CreatedAt.IsZero() { + out.CreatedAt = manifest.CreatedAt + } + if !manifest.ExpiresAt.IsZero() { + out.ExpiresAt = manifest.ExpiresAt + } + out.PasswordProtected = boxstore.IsPasswordProtected(manifest) + out.OneTimeDownload = manifest.OneTimeDownload + out.RetentionKey = manifest.RetentionKey + out.RetentionLabel = manifest.RetentionLabel + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +// ── Change output ──────────────────────────────────────────── + +func formatChangeResultJSON(boxID string, manifest models.BoxManifest) error { + type changeOut struct { + BoxID string `json:"box_id"` + Updated bool `json:"updated"` + CreatedAt time.Time `json:"created_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Expired bool `json:"expired"` + PasswordProtected bool `json:"password_protected"` + OneTimeDownload bool `json:"one_time_download"` + DisableZip bool `json:"disable_zip"` + RetentionKey string `json:"retention_key,omitempty"` + RetentionLabel string `json:"retention_label,omitempty"` + RetentionSeconds int64 `json:"retention_seconds,omitempty"` + FileCount int `json:"file_count"` + } + out := changeOut{ + BoxID: boxID, Updated: true, + Expired: boxstore.IsExpired(manifest), + PasswordProtected: boxstore.IsPasswordProtected(manifest), + OneTimeDownload: manifest.OneTimeDownload, + DisableZip: manifest.DisableZip, + RetentionKey: manifest.RetentionKey, + RetentionLabel: manifest.RetentionLabel, + RetentionSeconds: manifest.RetentionSecs, + FileCount: len(manifest.Files), + } + if !manifest.CreatedAt.IsZero() { + out.CreatedAt = manifest.CreatedAt + } + if !manifest.ExpiresAt.IsZero() { + out.ExpiresAt = manifest.ExpiresAt + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +// ── Retention options ──────────────────────────────────────── + func printRetentionOptions() { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "Key\tLabel\tSeconds")