package main import ( "encoding/json" "fmt" "os" "sort" "strconv" "strings" "time" "github.com/spf13/cobra" "github.com/spf13/pflag" "golang.org/x/crypto/bcrypt" "warpbox/lib/boxstore" "warpbox/lib/helpers" "warpbox/lib/models" ) func newBoxCommand() *cobra.Command { cmd := &cobra.Command{ Use: "box", Short: "Manage boxes", Long: "Manage WarpBox upload boxes: list, view, inspect, delete, modify.", } cmd.AddCommand(newBoxListCommand()) cmd.AddCommand(newBoxViewCommand()) cmd.AddCommand(newBoxInspectCommand()) cmd.AddCommand(newBoxDeleteCommand()) cmd.AddCommand(newBoxChangeCommand()) cmd.AddCommand(newBoxGetCommand()) return cmd } 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) } summaries, err := boxstore.ListBoxSummaries() if err != nil { return fmt.Errorf("failed to list boxes: %w", err) } if len(summaries) == 0 { 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) case "table", "": return formatBoxSummariesTable(summaries) default: return fmt.Errorf("unknown format: %s (use 'table' or 'json')", format) } }, } 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", Long: "View a box summary showing files, size, expiry, etc.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if uploadRoot != "" { boxstore.SetUploadRoot(uploadRoot) } boxID := args[0] summary, err := boxstore.BoxSummary(boxID) 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)", Long: "Print the full box manifest as JSON. Use --full for hidden fields.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if uploadRoot != "" { boxstore.SetUploadRoot(uploadRoot) } boxID := args[0] manifest, err := boxstore.ReadManifest(boxID) if err != nil { return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err) } if !full { sanitized := manifest sanitized.PasswordHash = "[REDACTED]" sanitized.PasswordSalt = "[REDACTED]" sanitized.AuthToken = "[REDACTED]" manifest = sanitized } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(manifest) }, } 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"}, Short: "Delete a box", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if uploadRoot != "" { boxstore.SetUploadRoot(uploadRoot) } boxID := args[0] if !force { fmt.Printf("This will permanently delete box %s and all its files.\n", boxID) fmt.Print("Confirm (y/N): ") var confirm string if _, err := fmt.Scanln(&confirm); err != nil { 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 } func newBoxChangeCommand() *cobra.Command { var uploadRoot string var retention int64 var retentionList bool var password string var zip bool var oneTime bool var renew bool var renewSeconds int64 var asJSON bool cmd := &cobra.Command{ Use: "change", Aliases: []string{"update", "modify"}, Short: "Change box properties", Long: "Change box properties: retention, password, zip, one-time download, renew expiry.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if uploadRoot != "" { boxstore.SetUploadRoot(uploadRoot) } boxID := args[0] if retentionList { printRetentionOptions() return nil } changes, err := gatherBoxChanges(cmd.Flags(), retention, password, zip, oneTime, renew, renewSeconds) if err != nil { return err } if len(changes) == 0 { fmt.Println("No changes specified. Use --retention, --password, --zip, --one-time, --renew, or --retention-list.") return nil } manifest, err := boxstore.ReadManifest(boxID) if err != nil { return fmt.Errorf("failed to read manifest for box %s: %w", boxID, err) } for _, apply := range changes { if err := apply(&manifest); err != nil { return err } } if err := boxstore.WriteManifest(boxID, manifest); err != nil { 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 }, } cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory") cmd.Flags().Int64Var(&retention, "retention", 0, "Set retention seconds (use --retention-list for valid values)") cmd.Flags().BoolVar(&retentionList, "retention-list", false, "List available retention options") cmd.Flags().StringVar(&password, "password", "", "Set a new password (empty string to remove)") cmd.Flags().BoolVar(&zip, "zip", true, "Allow ZIP downloads (default true, --zip=false to disable)") 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 } type changeFunc func(*models.BoxManifest) error func gatherBoxChanges(flags *pflag.FlagSet, retention int64, password string, zip bool, oneTime bool, renew bool, renewSeconds int64) ([]changeFunc, error) { var changes []changeFunc if flags.Changed("retention") { if retention < 0 { return nil, fmt.Errorf("retention cannot be negative") } changes = append(changes, func(m *models.BoxManifest) error { if m.OneTimeDownload { m.OneTimeDownload = false } m.RetentionSecs = retention for _, opt := range boxstore.RetentionOptions() { if opt.Seconds == retention { m.RetentionKey = opt.Key m.RetentionLabel = opt.Label return nil } } m.RetentionKey = "custom" return nil }) } if flags.Changed("password") { changes = append(changes, func(m *models.BoxManifest) error { if password == "" { m.PasswordHash = "" m.PasswordHashAlg = "" m.AuthToken = "" return nil } token, err := helpers.RandomHexID(16) if err != nil { return fmt.Errorf("could not generate auth token") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("could not hash password: %w", err) } m.PasswordHash = string(hash) m.PasswordHashAlg = "bcrypt" m.AuthToken = token return nil }) } if flags.Changed("zip") { changes = append(changes, func(m *models.BoxManifest) error { if m.OneTimeDownload { return nil } m.DisableZip = !zip return nil }) } if flags.Changed("one-time") { changes = append(changes, func(m *models.BoxManifest) error { if oneTime { m.OneTimeDownload = true m.DisableZip = false if boxstore.OneTimeDownloadExpiry() > 0 { m.RetentionSecs = boxstore.OneTimeDownloadExpiry() } } else { m.OneTimeDownload = false } return nil }) } if flags.Changed("renew") { changes = append(changes, func(m *models.BoxManifest) error { secs := renewSeconds if secs <= 0 { secs = m.RetentionSecs } return renewBoxExpiry(m, secs) }) } return changes, nil } func renewBoxExpiry(m *models.BoxManifest, seconds int64) error { if seconds <= 0 || m.OneTimeDownload { return nil } if m.ExpiresAt.IsZero() { m.ExpiresAt = time.Now().UTC().Add(time.Duration(seconds) * time.Second) return nil } m.ExpiresAt = m.ExpiresAt.Add(time.Duration(seconds) * time.Second) return nil } func newBoxGetCommand() *cobra.Command { var uploadRoot string var asJSON bool cmd := &cobra.Command{ Use: "get", Short: "Get box URL and info", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if uploadRoot != "" { boxstore.SetUploadRoot(uploadRoot) } boxID := args[0] manifest, err := boxstore.ReadManifest(boxID) if err != nil { 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() { fmt.Printf("Created:\t%s\n", manifest.CreatedAt.Format(time.RFC3339)) } if !manifest.ExpiresAt.IsZero() { fmt.Printf("Expires:\t%s\n", manifest.ExpiresAt.Format(time.RFC3339)) } if boxstore.IsPasswordProtected(manifest) { fmt.Println("Password:\tprotected") } if manifest.OneTimeDownload { fmt.Println("Mode:\tone-time download") } return nil }, } cmd.Flags().StringVar(&uploadRoot, "upload-root", "", "Override upload root directory") cmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON") return cmd }