feat(cli): add robust box listing filters and sorting

This commit is contained in:
2026-04-30 12:46:44 +03:00
parent b0bdf798a9
commit b8bb75f7e0
4 changed files with 291 additions and 713 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
data/
.env
docker-compose.yml
dev
# Go
bin/

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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")