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/
|
data/
|
||||||
.env
|
.env
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
dev
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
bin/
|
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.
|
|
||||||
192
cmd/cmd_box.go
192
cmd/cmd_box.go
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,10 +38,20 @@ func newBoxCommand() *cobra.Command {
|
|||||||
func newBoxListCommand() *cobra.Command {
|
func newBoxListCommand() *cobra.Command {
|
||||||
var format string
|
var format string
|
||||||
var uploadRoot 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{
|
cmd := &cobra.Command{
|
||||||
Use: "ls",
|
Use: "ls",
|
||||||
Aliases: []string{"list", "view"},
|
Aliases: []string{"list", "view"},
|
||||||
Short: "List all boxes",
|
Short: "List all boxes",
|
||||||
|
Long: "List all boxes with optional sorting and filtering.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if uploadRoot != "" {
|
if uploadRoot != "" {
|
||||||
boxstore.SetUploadRoot(uploadRoot)
|
boxstore.SetUploadRoot(uploadRoot)
|
||||||
@@ -52,6 +64,18 @@ func newBoxListCommand() *cobra.Command {
|
|||||||
fmt.Println("No boxes found.")
|
fmt.Println("No boxes found.")
|
||||||
return nil
|
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 {
|
switch format {
|
||||||
case "json":
|
case "json":
|
||||||
return formatBoxSummariesJSON(summaries)
|
return formatBoxSummariesJSON(summaries)
|
||||||
@@ -64,11 +88,140 @@ func newBoxListCommand() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json")
|
cmd.Flags().StringVarP(&format, "format", "o", "table", "Output format: table, json")
|
||||||
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
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
|
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 {
|
func newBoxViewCommand() *cobra.Command {
|
||||||
var uploadRoot string
|
var uploadRoot string
|
||||||
|
var asJSON bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "view",
|
Use: "view",
|
||||||
Short: "View box summary",
|
Short: "View box summary",
|
||||||
@@ -83,17 +236,22 @@ func newBoxViewCommand() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to view box %s: %w", boxID, err)
|
return fmt.Errorf("failed to view box %s: %w", boxID, err)
|
||||||
}
|
}
|
||||||
|
if asJSON {
|
||||||
|
return formatBoxSummaryJSON(&summary)
|
||||||
|
}
|
||||||
printBoxSummary(&summary)
|
printBoxSummary(&summary)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBoxInspectCommand() *cobra.Command {
|
func newBoxInspectCommand() *cobra.Command {
|
||||||
var uploadRoot string
|
var uploadRoot string
|
||||||
var full bool
|
var full bool
|
||||||
|
var asJSON bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "inspect",
|
Use: "inspect",
|
||||||
Short: "Inspect box manifest (raw JSON)",
|
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().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
cmd.Flags().BoolVar(&full, "full", false, "Show sensitive fields (password hash, auth token)")
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBoxDeleteCommand() *cobra.Command {
|
func newBoxDeleteCommand() *cobra.Command {
|
||||||
var uploadRoot string
|
var uploadRoot string
|
||||||
var force bool
|
var force bool
|
||||||
|
var asJSON bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "rm",
|
Use: "rm",
|
||||||
Aliases: []string{"del", "delete"},
|
Aliases: []string{"del", "delete"},
|
||||||
@@ -146,19 +307,33 @@ func newBoxDeleteCommand() *cobra.Command {
|
|||||||
confirm = "n"
|
confirm = "n"
|
||||||
}
|
}
|
||||||
if strings.ToLower(strings.TrimSpace(confirm)) != "y" {
|
if strings.ToLower(strings.TrimSpace(confirm)) != "y" {
|
||||||
fmt.Println("Aborted.")
|
if asJSON {
|
||||||
|
fmt.Println(`{"deleted": false, "reason": "aborted"}`)
|
||||||
|
} else {
|
||||||
|
fmt.Println("Aborted.")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := boxstore.DeleteBox(boxID); err != 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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +346,7 @@ func newBoxChangeCommand() *cobra.Command {
|
|||||||
var oneTime bool
|
var oneTime bool
|
||||||
var renew bool
|
var renew bool
|
||||||
var renewSeconds int64
|
var renewSeconds int64
|
||||||
|
var asJSON bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "change",
|
Use: "change",
|
||||||
@@ -214,6 +390,9 @@ func newBoxChangeCommand() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to save manifest for box %s: %w", boxID, err)
|
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)
|
fmt.Printf("Box %s updated.\n", boxID)
|
||||||
return nil
|
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(&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().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().Int64Var(&renewSeconds, "renew-seconds", 0, "Seconds to extend expiry by (used with --renew)")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -332,6 +512,7 @@ func renewBoxExpiry(m *models.BoxManifest, seconds int64) error {
|
|||||||
|
|
||||||
func newBoxGetCommand() *cobra.Command {
|
func newBoxGetCommand() *cobra.Command {
|
||||||
var uploadRoot string
|
var uploadRoot string
|
||||||
|
var asJSON bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "get",
|
Use: "get",
|
||||||
Short: "Get box URL and info",
|
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)
|
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("Box ID:\t%s\n", boxID)
|
||||||
fmt.Printf("URL:\t/box/%s\n", boxID)
|
fmt.Printf("URL:\t/box/%s\n", boxID)
|
||||||
if !manifest.CreatedAt.IsZero() {
|
if !manifest.CreatedAt.IsZero() {
|
||||||
@@ -364,5 +549,6 @@ func newBoxGetCommand() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory")
|
||||||
|
cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
"warpbox/lib/models"
|
"warpbox/lib/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── List output ──────────────────────────────────────────────
|
||||||
|
|
||||||
func formatBoxSummariesTable(summaries []models.BoxSummary) error {
|
func formatBoxSummariesTable(summaries []models.BoxSummary) error {
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
fmt.Fprintln(w, "ID\tFiles\tSize\tCreated\tExpires\tPassword\tOne-Time\tExpired")
|
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)
|
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) {
|
func printBoxSummary(s *models.BoxSummary) {
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
fmt.Fprintf(w, "ID:\t%s\n", s.ID)
|
fmt.Fprintf(w, "ID:\t%s\n", s.ID)
|
||||||
@@ -70,6 +97,80 @@ func printBoxSummary(s *models.BoxSummary) {
|
|||||||
w.Flush()
|
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() {
|
func printRetentionOptions() {
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
fmt.Fprintln(w, "Key\tLabel\tSeconds")
|
fmt.Fprintln(w, "Key\tLabel\tSeconds")
|
||||||
|
|||||||
Reference in New Issue
Block a user