feat(cli): add robust box listing filters and sorting
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
data/
|
||||
.env
|
||||
docker-compose.yml
|
||||
dev
|
||||
|
||||
# Go
|
||||
bin/
|
||||
|
||||
@@ -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.
|
||||
186
cmd/cmd_box.go
186
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" {
|
||||
if asJSON {
|
||||
fmt.Println(`{"deleted": false, "reason": "aborted"}`)
|
||||
} else {
|
||||
fmt.Println("Aborted.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := boxstore.DeleteBox(boxID); err != nil {
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user