diff options
| author | s <[email protected]> | 2025-11-13 14:43:15 -0500 |
|---|---|---|
| committer | s <[email protected]> | 2025-11-13 14:43:15 -0500 |
| commit | 344a6f6415c3c1b593677adec3b8844e0839971b (patch) | |
| tree | b05291ecdf21917b27e9e234eeb997c2706966d5 | |
| parent | a5fc01a03753c9a18ddeaf13610dd99b4b311b80 (diff) | |
| download | dborg-344a6f6415c3c1b593677adec3b8844e0839971b.tar.gz dborg-344a6f6415c3c1b593677adec3b8844e0839971b.zip | |
created pretty printing for all commandsv1.0.0
| -rw-r--r-- | cmd/admin.go | 58 | ||||
| -rw-r--r-- | cmd/dns.go | 12 | ||||
| -rw-r--r-- | cmd/npd.go | 11 | ||||
| -rw-r--r-- | cmd/osint.go | 29 | ||||
| -rw-r--r-- | cmd/reddit.go | 37 | ||||
| -rw-r--r-- | cmd/root.go | 9 | ||||
| -rw-r--r-- | cmd/skiptrace.go | 48 | ||||
| -rw-r--r-- | cmd/sl.go | 11 | ||||
| -rw-r--r-- | cmd/x.go | 67 | ||||
| -rw-r--r-- | internal/formatter/admin.go | 187 | ||||
| -rw-r--r-- | internal/formatter/breachforum.go | 186 | ||||
| -rw-r--r-- | internal/formatter/bssid.go | 47 | ||||
| -rw-r--r-- | internal/formatter/buckets.go | 470 | ||||
| -rw-r--r-- | internal/formatter/buckets_test.go | 509 | ||||
| -rw-r--r-- | internal/formatter/dns.go | 57 | ||||
| -rw-r--r-- | internal/formatter/files.go | 79 | ||||
| -rw-r--r-- | internal/formatter/formatter.go | 478 | ||||
| -rw-r--r-- | internal/formatter/geo.go | 10 | ||||
| -rw-r--r-- | internal/formatter/npd.go | 139 | ||||
| -rw-r--r-- | internal/formatter/reddit.go | 444 | ||||
| -rw-r--r-- | internal/formatter/shortlinks.go | 80 | ||||
| -rw-r--r-- | internal/formatter/skiptrace.go | 128 | ||||
| -rw-r--r-- | internal/formatter/sl.go | 125 | ||||
| -rw-r--r-- | internal/formatter/username.go | 143 | ||||
| -rw-r--r-- | internal/formatter/x.go | 379 | ||||
| -rw-r--r-- | internal/utils/output.go | 6 |
26 files changed, 3660 insertions, 89 deletions
diff --git a/cmd/admin.go b/cmd/admin.go index 82e2c29..3c20068 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -4,8 +4,8 @@ import ( "fmt" "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/formatter" "git.db.org.ai/dborg/internal/models" - "git.db.org.ai/dborg/internal/utils" "strconv" "github.com/spf13/cobra" @@ -93,7 +93,13 @@ func runAdminList(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - return utils.PrintJSON(response.Accounts) + output, err := formatter.FormatAccountList(response.Accounts, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) + return nil } func runAdminCreate(cmd *cobra.Command, args []string) error { @@ -122,17 +128,12 @@ func runAdminCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - if response.Account != nil { - fmt.Printf("Account created successfully!\n") - fmt.Printf("Name: %s\n", response.Account.Name) - fmt.Printf("API Key: %s\n", response.Account.APIKey) - fmt.Printf("Credits: %d\n", response.Account.Credits) - fmt.Printf("Unlimited: %v\n", response.Account.Unlimited) - fmt.Printf("Premium: %v\n", response.Account.IsPremium) - fmt.Printf("Disabled: %v\n", response.Account.Disabled) - } else { - fmt.Println(response.Message) + output, err := formatter.FormatAccountCreated(response.Account, response.Message, IsJSONOutput()) + if err != nil { + return err } + + fmt.Print(output) return nil } @@ -151,7 +152,12 @@ func runAdminDelete(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - fmt.Println(response.Message) + output, err := formatter.FormatAccountDeleted(response.Message, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) return nil } @@ -175,7 +181,12 @@ func runAdminCredits(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - fmt.Println(response.Message) + output, err := formatter.FormatCreditsUpdated(response.Message, nil, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) return nil } @@ -199,14 +210,12 @@ func runAdminSetCredits(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - if response.Account != nil { - fmt.Printf("Credits set successfully!\n") - fmt.Printf("Account: %s\n", response.Account.Name) - fmt.Printf("API Key: %s\n", response.Account.APIKey) - fmt.Printf("Credits: %d\n", response.Account.Credits) - } else if response.Message != "" { - fmt.Println(response.Message) + output, err := formatter.FormatCreditsUpdated(response.Message, response.Account, IsJSONOutput()) + if err != nil { + return err } + + fmt.Print(output) return nil } @@ -226,6 +235,11 @@ func runAdminDisable(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - fmt.Println(response.Message) + output, err := formatter.FormatAccountToggled(response.Message, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) return nil } @@ -6,6 +6,7 @@ import ( "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/formatter" "git.db.org.ai/dborg/internal/models" "github.com/spf13/cobra" ) @@ -42,7 +43,16 @@ func runDNSTLDCheck(cmd *cobra.Command, args []string) error { fmt.Printf("Checking TLDs for term: %s\n\n", term) err = c.CheckDNSTLDStream(params, func(result json.RawMessage) error { - fmt.Println(string(result)) + var domainResult models.DomainResult + if err := json.Unmarshal(result, &domainResult); err != nil { + return fmt.Errorf("failed to parse result: %w", err) + } + + output, err := formatter.FormatDNSResults(&domainResult, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) return nil }) @@ -2,11 +2,11 @@ package cmd import ( "fmt" + "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/formatter" "git.db.org.ai/dborg/internal/models" - "git.db.org.ai/dborg/internal/utils" - "github.com/spf13/cobra" ) @@ -84,5 +84,10 @@ func runNPDSearch(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - return utils.PrintJSON(response.Results.Hits) + output, err := formatter.FormatNPDResults(response, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) + return nil } diff --git a/cmd/osint.go b/cmd/osint.go index 3c3d0de..e29ef54 100644 --- a/cmd/osint.go +++ b/cmd/osint.go @@ -5,8 +5,8 @@ import ( "fmt" "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/formatter" "git.db.org.ai/dborg/internal/models" - "git.db.org.ai/dborg/internal/utils" "github.com/spf13/cobra" ) @@ -149,8 +149,17 @@ func runUsernameCheck(cmd *cobra.Command, args []string) error { params.MaxTasks, _ = cmd.Flags().GetInt("max_tasks") err = c.CheckUsernameStream(params, func(result json.RawMessage) error { - fmt.Println(string(result)) - return nil + if IsJSONOutput() { + fmt.Println(string(result)) + return nil + } + + var siteResult models.SiteResult + if err := json.Unmarshal(result, &siteResult); err != nil { + return err + } + + return formatter.FormatUsernameSiteResult(&siteResult) }) if err != nil { @@ -180,7 +189,7 @@ func runBSSIDLookup(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + return formatter.FormatBSSIDResults(*response, IsJSONOutput()) } func runBreachForumSearch(cmd *cobra.Command, args []string) error { @@ -201,7 +210,7 @@ func runBreachForumSearch(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + return formatter.FormatBreachForumResults(response, IsJSONOutput()) } func runFilesSearch(cmd *cobra.Command, args []string) error { @@ -226,7 +235,7 @@ func runFilesSearch(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + return formatter.FormatFilesResults(*response, IsJSONOutput()) } func runBucketsSearch(cmd *cobra.Command, args []string) error { @@ -246,7 +255,7 @@ func runBucketsSearch(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + return formatter.FormatBucketsResults(response, IsJSONOutput()) } func runBucketFilesSearch(cmd *cobra.Command, args []string) error { @@ -269,7 +278,7 @@ func runBucketFilesSearch(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + return formatter.FormatBucketFilesResults(response, IsJSONOutput()) } func runShortlinksSearch(cmd *cobra.Command, args []string) error { @@ -294,7 +303,7 @@ func runShortlinksSearch(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + return formatter.FormatShortlinksResults(response, IsJSONOutput()) } func runGeoSearch(cmd *cobra.Command, args []string) error { @@ -316,7 +325,7 @@ func runGeoSearch(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + return formatter.FormatGeoResults(*response, IsJSONOutput()) } func runCrawl(cmd *cobra.Command, args []string) error { diff --git a/cmd/reddit.go b/cmd/reddit.go index 194fdab..5969f7c 100644 --- a/cmd/reddit.go +++ b/cmd/reddit.go @@ -5,8 +5,8 @@ import ( "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/formatter" "git.db.org.ai/dborg/internal/models" - "git.db.org.ai/dborg/internal/utils" "github.com/spf13/cobra" ) @@ -102,7 +102,12 @@ func runRedditSubredditPosts(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - return utils.PrintJSON(response) + output, err := formatter.FormatRedditResults(response, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) + return nil } func runRedditSubredditComments(cmd *cobra.Command, args []string) error { @@ -126,7 +131,12 @@ func runRedditSubredditComments(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - return utils.PrintJSON(response) + output, err := formatter.FormatRedditResults(response, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) + return nil } func runRedditUserPosts(cmd *cobra.Command, args []string) error { @@ -150,7 +160,12 @@ func runRedditUserPosts(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - return utils.PrintJSON(response) + output, err := formatter.FormatRedditResults(response, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) + return nil } func runRedditUserComments(cmd *cobra.Command, args []string) error { @@ -174,7 +189,12 @@ func runRedditUserComments(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - return utils.PrintJSON(response) + output, err := formatter.FormatRedditResults(response, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) + return nil } func runRedditUserAbout(cmd *cobra.Command, args []string) error { @@ -198,5 +218,10 @@ func runRedditUserAbout(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - return utils.PrintJSON(response) + output, err := formatter.FormatRedditResults(response, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) + return nil } diff --git a/cmd/root.go b/cmd/root.go index 890d336..c4239ba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -8,6 +8,10 @@ import ( "github.com/spf13/cobra" ) +var ( + jsonOutput bool +) + var rootCmd = &cobra.Command{ Use: "dborg", Short: "DB.org.ai CLI client", @@ -28,4 +32,9 @@ func Execute() { } func init() { + rootCmd.PersistentFlags().BoolVarP(&jsonOutput, "json", "j", false, "Output raw JSON instead of formatted text") +} + +func IsJSONOutput() bool { + return jsonOutput } diff --git a/cmd/skiptrace.go b/cmd/skiptrace.go index 9ce1b4b..307204c 100644 --- a/cmd/skiptrace.go +++ b/cmd/skiptrace.go @@ -4,8 +4,8 @@ import ( "fmt" "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/formatter" "git.db.org.ai/dborg/internal/models" - "git.db.org.ai/dborg/internal/utils" "strconv" "github.com/spf13/cobra" @@ -90,10 +90,16 @@ func runSkiptracePeople(cmd *cobra.Command, args []string) error { return err } - if response.Data != nil && len(response.Data) > 0 { - return utils.PrintJSON(response.Data) + if response.Error != "" { + return fmt.Errorf("API error: %s", response.Error) } + output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) return nil } @@ -118,16 +124,12 @@ func runSkiptraceReport(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - if response.Data != nil && len(response.Data) > 0 { - if err := utils.PrintJSON(response.Data); err != nil { - return err - } - } - - if response.Message != "" { - fmt.Println(response.Message) + output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput()) + if err != nil { + return err } + fmt.Print(output) return nil } @@ -146,16 +148,12 @@ func runSkiptracePhone(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - if response.Data != nil && len(response.Data) > 0 { - if err := utils.PrintJSON(response.Data); err != nil { - return err - } - } - - if response.Message != "" { - fmt.Println(response.Message) + output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput()) + if err != nil { + return err } + fmt.Print(output) return nil } @@ -174,15 +172,11 @@ func runSkiptraceEmail(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - if response.Data != nil && len(response.Data) > 0 { - if err := utils.PrintJSON(response.Data); err != nil { - return err - } - } - - if response.Message != "" { - fmt.Println(response.Message) + output, err := formatter.FormatSkiptraceResults(response, IsJSONOutput()) + if err != nil { + return err } + fmt.Print(output) return nil } @@ -2,11 +2,11 @@ package cmd import ( "fmt" + "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" + "git.db.org.ai/dborg/internal/formatter" "git.db.org.ai/dborg/internal/models" - "git.db.org.ai/dborg/internal/utils" - "github.com/spf13/cobra" ) @@ -66,5 +66,10 @@ func runSLSearch(cmd *cobra.Command, args []string) error { return nil } - return utils.PrintJSON(response.Results) + output, err := formatter.FormatSLResults(response, IsJSONOutput()) + if err != nil { + return err + } + fmt.Print(output) + return nil } @@ -5,7 +5,8 @@ import ( "fmt" "git.db.org.ai/dborg/internal/client" "git.db.org.ai/dborg/internal/config" - "git.db.org.ai/dborg/internal/utils" + "git.db.org.ai/dborg/internal/formatter" + "git.db.org.ai/dborg/internal/models" "github.com/spf13/cobra" ) @@ -94,16 +95,12 @@ func runXHistorySearch(cmd *cobra.Command, args []string) error { return fmt.Errorf("API error: %s", response.Error) } - if len(response.PreviousUsernames) > 0 { - return utils.PrintJSON(response.PreviousUsernames) - } else if response.Response != "" { - fmt.Println(response.Response) - } else if response.Data != nil { - return utils.PrintJSON(response.Data) - } else { - fmt.Println("No username history found") + output, err := formatter.FormatXHistory(response, IsJSONOutput()) + if err != nil { + return err } + fmt.Print(output) return nil } @@ -116,7 +113,17 @@ func runXTweetsSearch(cmd *cobra.Command, args []string) error { } err = c.FetchTweetsStream(args[0], func(result json.RawMessage) error { - fmt.Println(string(result)) + var streamResp models.TweetsStreamResponse + if err := json.Unmarshal(result, &streamResp); err != nil { + return err + } + + output, err := formatter.FormatXTweets(&streamResp, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) return nil }) @@ -140,7 +147,13 @@ func runXFirstFollowers(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + output, err := formatter.FormatXFirstFollowers(response, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) + return nil } func runXNotableFollowers(cmd *cobra.Command, args []string) error { @@ -156,7 +169,13 @@ func runXNotableFollowers(cmd *cobra.Command, args []string) error { return err } - return utils.PrintJSON(response) + output, err := formatter.FormatXNotableFollowers(response, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) + return nil } func runXReplies(cmd *cobra.Command, args []string) error { @@ -169,7 +188,17 @@ func runXReplies(cmd *cobra.Command, args []string) error { } err = c.FetchRepliesStream(args[0], limit, func(result json.RawMessage) error { - fmt.Println(string(result)) + var streamResp models.TweetsStreamResponse + if err := json.Unmarshal(result, &streamResp); err != nil { + return err + } + + output, err := formatter.FormatXReplies(&streamResp, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) return nil }) @@ -190,7 +219,17 @@ func runXSearch(cmd *cobra.Command, args []string) error { } err = c.SearchTweetsStream(args[0], limit, func(result json.RawMessage) error { - fmt.Println(string(result)) + var streamResp models.TweetsStreamResponse + if err := json.Unmarshal(result, &streamResp); err != nil { + return err + } + + output, err := formatter.FormatXSearch(&streamResp, IsJSONOutput()) + if err != nil { + return err + } + + fmt.Print(output) return nil }) diff --git a/internal/formatter/admin.go b/internal/formatter/admin.go new file mode 100644 index 0000000..914eaaa --- /dev/null +++ b/internal/formatter/admin.go @@ -0,0 +1,187 @@ +package formatter + +import ( + "fmt" + "strings" + "time" + + "git.db.org.ai/dborg/internal/models" +) + +func FormatAccountList(accounts []models.Account, asJSON bool) (string, error) { + if asJSON { + err := PrintColorizedJSON(accounts) + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return "", nil + } + + if len(accounts) == 0 { + return fmt.Sprintf("%s\n", Gray("No accounts found")), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan("Account List")))) + sb.WriteString(fmt.Sprintf("%s\n\n", Gray(strings.Repeat("─", 80)))) + + table := NewTable([]string{"Name", "API Key", "Credits", "Status", "Premium", "Created"}) + + for _, acc := range accounts { + creditsStr := fmt.Sprintf("%d", acc.Credits) + if acc.Unlimited { + creditsStr = Green("Unlimited") + } else if acc.Credits > 1000 { + creditsStr = Green(creditsStr) + } else if acc.Credits > 100 { + creditsStr = Yellow(creditsStr) + } else { + creditsStr = Red(creditsStr) + } + + statusStr := Green("Active") + if acc.Disabled { + statusStr = Red("Disabled") + } + + premiumStr := Gray("No") + if acc.IsPremium { + premiumStr = Magenta("Yes") + } + + createdStr := Gray("-") + if acc.CreatedAt != nil { + if createdAt, ok := acc.CreatedAt.(string); ok && createdAt != "" { + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { + createdStr = t.Format("2006-01-02") + } else { + createdStr = createdAt + } + } + } + + table.AddRow( + Bold(acc.Name), + Dim(acc.APIKey), + creditsStr, + statusStr, + premiumStr, + createdStr, + ) + } + + sb.WriteString(table.Render()) + sb.WriteString(fmt.Sprintf("\n%s %d accounts\n", Blue("Total:"), len(accounts))) + + return sb.String(), nil +} + +func FormatAccountCreated(account *models.Account, message string, asJSON bool) (string, error) { + if asJSON { + response := map[string]interface{}{ + "account": account, + "message": message, + } + err := PrintColorizedJSON(response) + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return "", nil + } + + if account == nil { + return fmt.Sprintf("%s\n", message), nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("\n%s\n\n", Bold(Green("✓ Account created successfully!")))) + + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Name:"), Bold(account.Name))) + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("API Key:"), Bold(Yellow(account.APIKey)))) + + if account.Unlimited { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), Green("Unlimited"))) + } else { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), FormatCredits(int64(account.Credits)))) + } + + if account.IsPremium { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Premium:"), Magenta("Yes"))) + } else { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Premium:"), Gray("No"))) + } + + if account.Disabled { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Status:"), Red("Disabled"))) + } else { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Status:"), Green("Active"))) + } + + sb.WriteString(fmt.Sprintf("\n%s\n", Dim("Save the API key securely - it cannot be retrieved later!"))) + + return sb.String(), nil +} + +func FormatAccountDeleted(message string, asJSON bool) (string, error) { + if asJSON { + response := map[string]string{"message": message} + err := PrintColorizedJSON(response) + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return "", nil + } + + return fmt.Sprintf("%s %s\n", Green("✓"), message), nil +} + +func FormatCreditsUpdated(message string, account *models.Account, asJSON bool) (string, error) { + if asJSON { + response := map[string]interface{}{ + "message": message, + "account": account, + } + err := PrintColorizedJSON(response) + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return "", nil + } + + var sb strings.Builder + + if account != nil { + sb.WriteString(fmt.Sprintf("\n%s\n\n", Bold(Green("✓ Credits updated successfully!")))) + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Account:"), Bold(account.Name))) + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("API Key:"), Dim(account.APIKey))) + + if account.Unlimited { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), Green("Unlimited"))) + } else { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Credits:"), FormatCredits(int64(account.Credits)))) + } + } else { + sb.WriteString(fmt.Sprintf("%s %s\n", Green("✓"), message)) + } + + return sb.String(), nil +} + +func FormatAccountToggled(message string, asJSON bool) (string, error) { + if asJSON { + response := map[string]string{"message": message} + err := PrintColorizedJSON(response) + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return "", nil + } + + if strings.Contains(strings.ToLower(message), "enabled") { + return fmt.Sprintf("%s %s\n", Green("✓"), message), nil + } else if strings.Contains(strings.ToLower(message), "disabled") { + return fmt.Sprintf("%s %s\n", Yellow("⚠"), message), nil + } + + return fmt.Sprintf("%s %s\n", Blue("ℹ"), message), nil +} diff --git a/internal/formatter/breachforum.go b/internal/formatter/breachforum.go new file mode 100644 index 0000000..6f77fdf --- /dev/null +++ b/internal/formatter/breachforum.go @@ -0,0 +1,186 @@ +package formatter + +import ( + "fmt" + "strings" + "time" + + "git.db.org.ai/dborg/internal/models" + "git.db.org.ai/dborg/internal/utils" +) + +func FormatBreachForumResults(response *models.BreachForumSearchResponse, asJSON bool) error { + if asJSON { + return utils.PrintJSON(response) + } + + PrintSection(fmt.Sprintf("BreachForum Search: %s", Bold(response.Query))) + fmt.Printf("%s: %d\n", Dim("Max Hits"), response.MaxHits) + + if response.Results == nil { + PrintWarning("No results found") + return nil + } + + results, ok := response.Results.(map[string]any) + if !ok { + return fmt.Errorf("unexpected results format") + } + + if elapsed, ok := results["elapsed_time_micros"].(float64); ok { + fmt.Printf("%s: %s\n", Dim("Search Time"), formatElapsedTime(elapsed)) + } + + if numHits, ok := results["num_hits"].(float64); ok { + fmt.Printf("%s: %s\n", Dim("Total Results"), Yellow(fmt.Sprintf("%d", int(numHits)))) + } + + fmt.Println() + + hits, ok := results["hits"].([]any) + if !ok || len(hits) == 0 { + PrintWarning("No results found") + return nil + } + + for i, hit := range hits { + if hitMap, ok := hit.(map[string]any); ok { + formatBreachHit(hitMap, i+1, len(hits)) + } + } + + if errors, ok := results["errors"].([]any); ok && len(errors) > 0 { + fmt.Printf("\n%s\n", Bold(Red("Errors"))) + for _, err := range errors { + fmt.Printf(" %s %s\n", StatusError.String(), err) + } + } + + return nil +} + +func formatBreachHit(hit map[string]any, index, total int) { + fmt.Printf("%s %s\n", Gray(fmt.Sprintf("[%d/%d]", index, total)), Bold("Result")) + + if author, ok := hit["author"].(string); ok && author != "" { + cleanAuthor := strings.TrimSpace(strings.TrimPrefix(author, " ")) + fmt.Printf(" %s: %s\n", Cyan("Author"), cleanAuthor) + } + + if source, ok := hit["source"].(string); ok && source != "" { + sourceColor := getSourceColor(source) + fmt.Printf(" %s: %s\n", Cyan("Source"), Colorize(source, sourceColor)) + } + + if hitType, ok := hit["type"].(string); ok && hitType != "" { + typeColor := getTypeColor(hitType) + fmt.Printf(" %s: %s\n", Cyan("Type"), Colorize(hitType, typeColor)) + } + + if detDate, ok := hit["detection_date"].(string); ok && detDate != "" { + formattedDate := formatDetectionDate(detDate) + fmt.Printf(" %s: %s\n", Cyan("Detected"), formattedDate) + } + + if content, ok := hit["content"].(string); ok && content != "" { + content = strings.TrimSpace(content) + if len(content) > 200 { + content = TruncateString(content, 200) + } + + lines := strings.Split(content, "\n") + fmt.Printf(" %s:\n", Cyan("Content")) + for _, line := range lines { + if strings.TrimSpace(line) != "" { + fmt.Printf(" %s\n", Dim(line)) + } + } + } + + fmt.Println() +} + +func formatElapsedTime(microseconds float64) string { + milliseconds := microseconds / 1000 + if milliseconds < 1000 { + return fmt.Sprintf("%.2fms", milliseconds) + } + seconds := milliseconds / 1000 + return fmt.Sprintf("%.2fs", seconds) +} + +func formatDetectionDate(dateStr string) string { + t, err := time.Parse(time.RFC3339, dateStr) + if err != nil { + return dateStr + } + + now := time.Now() + duration := now.Sub(t) + + var timeAgo string + switch { + case duration.Hours() < 1: + timeAgo = fmt.Sprintf("%d minutes ago", int(duration.Minutes())) + case duration.Hours() < 24: + timeAgo = fmt.Sprintf("%d hours ago", int(duration.Hours())) + case duration.Hours() < 168: + days := int(duration.Hours() / 24) + if days == 1 { + timeAgo = "1 day ago" + } else { + timeAgo = fmt.Sprintf("%d days ago", days) + } + case duration.Hours() < 730: + weeks := int(duration.Hours() / 168) + if weeks == 1 { + timeAgo = "1 week ago" + } else { + timeAgo = fmt.Sprintf("%d weeks ago", weeks) + } + default: + months := int(duration.Hours() / 730) + if months == 1 { + timeAgo = "1 month ago" + } else { + timeAgo = fmt.Sprintf("%d months ago", months) + } + } + + formattedDate := t.Format("2006-01-02 15:04") + return fmt.Sprintf("%s %s", Yellow(formattedDate), Gray(fmt.Sprintf("(%s)", timeAgo))) +} + +func getSourceColor(source string) string { + source = strings.ToLower(source) + switch { + case strings.Contains(source, "leakbase"): + return ColorRed + case strings.Contains(source, "blackhat"): + return ColorMagenta + case strings.Contains(source, "hard-tm"): + return ColorYellow + case strings.Contains(source, "htdark"): + return ColorGray + case strings.Contains(source, "crdcrew"): + return ColorBlue + default: + return ColorCyan + } +} + +func getTypeColor(hitType string) string { + hitType = strings.ToLower(hitType) + switch { + case strings.Contains(hitType, "credential"): + return ColorMagenta + case strings.Contains(hitType, "leak"): + return ColorRed + case strings.Contains(hitType, "breach"): + return ColorRed + case strings.Contains(hitType, "database"): + return ColorYellow + default: + return ColorCyan + } +} diff --git a/internal/formatter/bssid.go b/internal/formatter/bssid.go new file mode 100644 index 0000000..06aa3cc --- /dev/null +++ b/internal/formatter/bssid.go @@ -0,0 +1,47 @@ +package formatter + +import ( + "fmt" + + "git.db.org.ai/dborg/internal/models" + "git.db.org.ai/dborg/internal/utils" +) + +func FormatBSSIDResults(response models.BSSIDLookupResponse, asJSON bool) error { + if asJSON { + return utils.PrintJSON(response) + } + + if len(response) == 0 { + PrintWarning("No results found") + return nil + } + + PrintSection("BSSID Lookup Results") + + for i, result := range response { + if i > 0 { + PrintDivider() + } + + fmt.Printf("\n%s: %s\n", Cyan("BSSID"), Bold(result.BSSID)) + + if result.Location != nil { + fmt.Printf("%s: %s\n", Cyan("Latitude"), Yellow(fmt.Sprintf("%.6f", result.Location.Latitude))) + fmt.Printf("%s: %s\n", Cyan("Longitude"), Yellow(fmt.Sprintf("%.6f", result.Location.Longitude))) + fmt.Printf("%s: %s\n", Cyan("Accuracy"), Dim(fmt.Sprintf("%d meters", result.Location.Accuracy))) + + if result.GoogleMap != "" { + fmt.Printf("%s: %s\n", Cyan("Google Maps"), result.GoogleMap) + } + if result.OpenStreetMap != "" { + fmt.Printf("%s: %s\n", Cyan("OpenStreetMap"), result.OpenStreetMap) + } + } else { + fmt.Printf("%s\n", Gray("No location data available")) + } + } + + fmt.Println() + return nil +} diff --git a/internal/formatter/buckets.go b/internal/formatter/buckets.go new file mode 100644 index 0000000..9672b9d --- /dev/null +++ b/internal/formatter/buckets.go @@ -0,0 +1,470 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "git.db.org.ai/dborg/internal/models" + "git.db.org.ai/dborg/internal/utils" +) + +func FormatBucketsResults(response *models.BucketsSearchResponse, asJSON bool) error { + if asJSON { + return utils.PrintJSON(response) + } + + PrintSection("Bucket Search Results") + + if response.Credits.Unlimited { + fmt.Printf("%s: %s\n", Dim("Credits"), Green("Unlimited")) + } else { + fmt.Printf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(response.Credits.Remaining))) + } + + if response.Results == nil { + PrintWarning("No results found") + return nil + } + + results, ok := response.Results.(map[string]any) + if !ok { + return fmt.Errorf("unexpected results format") + } + + buckets, ok := results["buckets"].([]any) + if !ok || len(buckets) == 0 { + PrintWarning("No buckets found") + return nil + } + + fmt.Printf("%s: %s\n\n", Dim("Total Buckets"), Yellow(fmt.Sprintf("%d", len(buckets)))) + + bucketGroups := groupBucketsByType(buckets) + + for _, bType := range getOrderedTypes(bucketGroups) { + formatBucketGroup(bType, bucketGroups[bType]) + } + + printBucketStats(buckets) + + return nil +} + +func groupBucketsByType(buckets []any) map[string][]map[string]any { + groups := make(map[string][]map[string]any) + + for _, bucket := range buckets { + if bucketMap, ok := bucket.(map[string]any); ok { + bucketType := "unknown" + if bt, ok := bucketMap["type"].(string); ok { + bucketType = bt + } + groups[bucketType] = append(groups[bucketType], bucketMap) + } + } + + return groups +} + +func getOrderedTypes(groups map[string][]map[string]any) []string { + var types []string + for t := range groups { + types = append(types, t) + } + + sort.Slice(types, func(i, j int) bool { + order := map[string]int{"aws": 1, "gcp": 2, "azure": 3, "dos": 4, "unknown": 99} + iOrder, iOk := order[types[i]] + jOrder, jOk := order[types[j]] + if !iOk { + iOrder = 50 + } + if !jOk { + jOrder = 50 + } + if iOrder != jOrder { + return iOrder < jOrder + } + return len(groups[types[i]]) > len(groups[types[j]]) + }) + + return types +} + +func formatBucketGroup(bucketType string, buckets []map[string]any) { + typeHeader := getBucketTypeHeader(bucketType) + fmt.Printf("%s %s\n", typeHeader, Gray(fmt.Sprintf("(%d)", len(buckets)))) + fmt.Println(Dim(strings.Repeat("─", 60))) + + sort.Slice(buckets, func(i, j int) bool { + iCount := getFileCount(buckets[i]) + jCount := getFileCount(buckets[j]) + return iCount > jCount + }) + + for i, bucket := range buckets { + formatSingleBucket(bucket, i+1) + } + + fmt.Println() +} + +func formatSingleBucket(bucket map[string]any, index int) { + bucketName := "unknown" + if name, ok := bucket["bucket"].(string); ok { + bucketName = name + } + + fileCount := getFileCount(bucket) + id := getID(bucket) + + fmt.Printf(" %s %s\n", Gray(fmt.Sprintf("%d.", index)), truncateBucketName(bucketName)) + + if fileCount > 0 { + fileCountStr := formatFileCount(fileCount) + fmt.Printf(" %s: %s", Cyan("Files"), fileCountStr) + } else { + fmt.Printf(" %s: %s", Cyan("Files"), Gray("Empty")) + } + + if id > 0 { + fmt.Printf(" %s %s", Dim("•"), Gray(fmt.Sprintf("ID: %d", id))) + } + + fmt.Println() +} + +func truncateBucketName(name string) string { + maxLen := 50 + if len(name) <= maxLen { + return Bold(name) + } + + parts := strings.Split(name, ".") + if len(parts) > 2 { + provider := parts[len(parts)-2] + "." + parts[len(parts)-1] + remaining := maxLen - len(provider) - 4 + if remaining > 0 { + return Bold(TruncateString(strings.Join(parts[:len(parts)-2], "."), remaining) + "..." + provider) + } + } + + return Bold(TruncateString(name, maxLen)) +} + +func formatFileCount(count int) string { + switch { + case count > 10000: + return Red(fmt.Sprintf("%s", formatNumber(count))) + case count > 1000: + return Yellow(fmt.Sprintf("%s", formatNumber(count))) + case count > 100: + return Green(fmt.Sprintf("%s", formatNumber(count))) + default: + return Cyan(fmt.Sprintf("%d", count)) + } +} + +func formatNumber(n int) string { + if n < 1000 { + return fmt.Sprintf("%d", n) + } + + str := fmt.Sprintf("%d", n) + var result strings.Builder + for i, r := range str { + if i > 0 && (len(str)-i)%3 == 0 { + result.WriteString(",") + } + result.WriteRune(r) + } + return result.String() +} + +func getBucketTypeHeader(bucketType string) string { + headers := map[string]string{ + "aws": Bold(Yellow("☁ AWS S3 Buckets")), + "gcp": Bold(Blue("☁ Google Cloud Storage")), + "azure": Bold(Cyan("☁ Azure Storage")), + "dos": Bold(Green("☁ DigitalOcean Spaces")), + } + + if header, ok := headers[bucketType]; ok { + return header + } + return Bold(Gray("☁ " + strings.Title(bucketType) + " Buckets")) +} + +func getFileCount(bucket map[string]any) int { + if count, ok := bucket["fileCount"].(float64); ok { + return int(count) + } + return 0 +} + +func getID(bucket map[string]any) int { + if id, ok := bucket["id"].(float64); ok { + return int(id) + } + return 0 +} + +func printBucketStats(buckets []any) { + totalFiles := 0 + emptyBuckets := 0 + largeBuckets := 0 + + typeCounts := make(map[string]int) + + for _, bucket := range buckets { + if bucketMap, ok := bucket.(map[string]any); ok { + fileCount := getFileCount(bucketMap) + totalFiles += fileCount + + if fileCount == 0 { + emptyBuckets++ + } else if fileCount > 1000 { + largeBuckets++ + } + + if bucketType, ok := bucketMap["type"].(string); ok { + typeCounts[bucketType]++ + } + } + } + + fmt.Printf("%s\n", Bold("Summary Statistics")) + fmt.Println(Dim(strings.Repeat("─", 60))) + + fmt.Printf(" %s: %s\n", Cyan("Total Files"), Yellow(formatNumber(totalFiles))) + fmt.Printf(" %s: %s\n", Cyan("Average Files/Bucket"), + Yellow(fmt.Sprintf("%.1f", float64(totalFiles)/float64(len(buckets))))) + + if emptyBuckets > 0 { + fmt.Printf(" %s: %s\n", Cyan("Empty Buckets"), Gray(fmt.Sprintf("%d", emptyBuckets))) + } + + if largeBuckets > 0 { + fmt.Printf(" %s: %s\n", Cyan("Large Buckets (>1000 files)"), + Red(fmt.Sprintf("%d", largeBuckets))) + } + + fmt.Println() +} + +func FormatBucketFilesResults(response *models.BucketsFilesSearchResponse, asJSON bool) error { + if asJSON { + return utils.PrintJSON(response) + } + + PrintSection("Bucket Files Search Results") + + if response.Credits.Unlimited { + fmt.Printf("%s: %s\n", Dim("Credits"), Green("Unlimited")) + } else { + fmt.Printf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(response.Credits.Remaining))) + } + + if response.Results == nil { + PrintWarning("No results found") + return nil + } + + results, ok := response.Results.(map[string]any) + if !ok { + resultsJSON, err := json.MarshalIndent(response.Results, "", " ") + if err != nil { + return fmt.Errorf("failed to format results: %w", err) + } + fmt.Println(string(resultsJSON)) + return nil + } + + files, ok := results["files"].([]any) + if !ok || len(files) == 0 { + PrintWarning("No files found") + return nil + } + + fmt.Printf("%s: %s\n\n", Dim("Total Files"), Yellow(fmt.Sprintf("%d", len(files)))) + + fileGroups := groupFilesByBucket(files) + + for bucket, bucketFiles := range fileGroups { + formatFileGroup(bucket, bucketFiles) + } + + return nil +} + +func groupFilesByBucket(files []any) map[string][]map[string]any { + groups := make(map[string][]map[string]any) + + for _, file := range files { + if fileMap, ok := file.(map[string]any); ok { + bucket := "unknown" + if b, ok := fileMap["bucket"].(string); ok && b != "" { + bucket = b + } else if url, ok := fileMap["url"].(string); ok && url != "" { + bucket = extractBucketFromURL(url) + } + groups[bucket] = append(groups[bucket], fileMap) + } + } + + return groups +} + +func extractBucketFromURL(url string) string { + url = strings.TrimPrefix(url, "https://") + url = strings.TrimPrefix(url, "http://") + + parts := strings.Split(url, "/") + if len(parts) > 0 { + return parts[0] + } + + return "unknown" +} + +func formatFileGroup(bucket string, files []map[string]any) { + fmt.Printf("%s %s\n", Bold(truncateBucketName(bucket)), Gray(fmt.Sprintf("(%d files)", len(files)))) + fmt.Println(Dim(strings.Repeat("─", 60))) + + for i, file := range files { + formatSingleFile(file, i+1) + } + + fmt.Println() +} + +func formatSingleFile(file map[string]any, index int) { + fileName := "unknown" + url := "" + + if name, ok := file["file"].(string); ok && name != "" { + fileName = name + } else if u, ok := file["url"].(string); ok && u != "" { + url = u + fileName = extractFileNameFromURL(u) + } + + fmt.Printf(" %s %s\n", Gray(fmt.Sprintf("%d.", index)), formatFileName(fileName)) + + if url != "" { + fmt.Printf(" %s: %s\n", Cyan("URL"), Dim(url)) + } + + if size, ok := file["size"].(float64); ok && size > 0 { + fmt.Printf(" %s: %s\n", Cyan("Size"), formatFileSize(int64(size))) + } + + if modified, ok := file["lastModified"].(string); ok && modified != "" { + fmt.Printf(" %s: %s\n", Cyan("Modified"), Dim(modified)) + } +} + +func extractFileNameFromURL(url string) string { + parts := strings.Split(url, "/") + if len(parts) > 0 { + fileName := parts[len(parts)-1] + + if qIndex := strings.Index(fileName, "?"); qIndex != -1 { + fileName = fileName[:qIndex] + } + + if hIndex := strings.Index(fileName, "#"); hIndex != -1 { + fileName = fileName[:hIndex] + } + + if fileName == "" { + return "index" + } + + return fileName + } + return "unknown" +} + +func formatFileName(name string) string { + if name == "" || name == "unknown" { + return Gray("(unnamed file)") + } + + parts := strings.Split(name, "/") + if len(parts) > 1 { + path := strings.Join(parts[:len(parts)-1], "/") + file := parts[len(parts)-1] + + if file == "" { + file = "(directory)" + } + + file = decodeURLEncoding(file) + + ext := getFileExtension(file) + color := getExtensionColor(ext) + + return Gray(path+"/") + Colorize(file, color) + } + + name = decodeURLEncoding(name) + ext := getFileExtension(name) + color := getExtensionColor(ext) + + return Colorize(name, color) +} + +func decodeURLEncoding(s string) string { + decoded := strings.ReplaceAll(s, "%20", " ") + decoded = strings.ReplaceAll(decoded, "%28", "(") + decoded = strings.ReplaceAll(decoded, "%29", ")") + decoded = strings.ReplaceAll(decoded, "_", " ") + + return decoded +} + +func getFileExtension(filename string) string { + parts := strings.Split(filename, ".") + if len(parts) > 1 { + return strings.ToLower(parts[len(parts)-1]) + } + return "" +} + +func getExtensionColor(ext string) string { + switch ext { + case "pdf", "doc", "docx", "txt": + return ColorBlue + case "jpg", "jpeg", "png", "gif", "svg": + return ColorGreen + case "mp4", "avi", "mov", "mkv": + return ColorMagenta + case "zip", "tar", "gz", "rar": + return ColorYellow + case "sql", "db", "sqlite": + return ColorRed + case "json", "xml", "yaml", "yml": + return ColorCyan + default: + return ColorWhite + } +} + +func formatFileSize(bytes int64) string { + color := ColorWhite + switch { + case bytes > 1024*1024*100: + color = ColorRed + case bytes > 1024*1024*10: + color = ColorYellow + case bytes > 1024*1024: + color = ColorGreen + default: + color = ColorCyan + } + + return Colorize(FormatBytes(bytes), color) +} diff --git a/internal/formatter/buckets_test.go b/internal/formatter/buckets_test.go new file mode 100644 index 0000000..eabddf0 --- /dev/null +++ b/internal/formatter/buckets_test.go @@ -0,0 +1,509 @@ +package formatter + +import ( + "bytes" + "os" + "testing" + + "git.db.org.ai/dborg/internal/models" +) + +func TestFormatBucketsResults(t *testing.T) { + tests := []struct { + name string + response *models.BucketsSearchResponse + asJSON bool + wantErr bool + }{ + { + name: "format_buckets_with_multiple_types", + response: &models.BucketsSearchResponse{ + Credits: models.CreditsInfo{ + Unlimited: true, + }, + Results: map[string]any{ + "buckets": []any{ + map[string]any{ + "bucket": "test-bucket.s3.amazonaws.com", + "fileCount": float64(1500), + "id": float64(1), + "type": "aws", + }, + map[string]any{ + "bucket": "another.s3-eu-west-1.amazonaws.com", + "fileCount": float64(250), + "id": float64(2), + "type": "aws", + }, + map[string]any{ + "bucket": "storage.googleapis.com", + "fileCount": float64(10000), + "id": float64(3), + "type": "gcp", + }, + map[string]any{ + "bucket": "spaces.digitaloceanspaces.com", + "fileCount": float64(0), + "id": float64(4), + "type": "dos", + }, + }, + }, + }, + asJSON: false, + wantErr: false, + }, + { + name: "format_empty_buckets", + response: &models.BucketsSearchResponse{ + Credits: models.CreditsInfo{ + Remaining: 100, + Unlimited: false, + }, + Results: nil, + }, + asJSON: false, + wantErr: false, + }, + { + name: "format_json_output", + response: &models.BucketsSearchResponse{ + Credits: models.CreditsInfo{ + Unlimited: true, + }, + Results: map[string]any{ + "buckets": []any{ + map[string]any{ + "bucket": "test.s3.amazonaws.com", + "type": "aws", + }, + }, + }, + }, + asJSON: true, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := FormatBucketsResults(tt.response, tt.asJSON) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + os.Stdout = oldStdout + + if (err != nil) != tt.wantErr { + t.Errorf("FormatBucketsResults() error = %v, wantErr %v", err, tt.wantErr) + return + } + + output := buf.String() + if !tt.asJSON && tt.response.Results != nil { + if output == "" { + t.Error("Expected non-empty output for non-JSON formatting") + } + } + }) + } +} + +func TestFormatBucketFilesResults(t *testing.T) { + tests := []struct { + name string + response *models.BucketsFilesSearchResponse + asJSON bool + wantErr bool + }{ + { + name: "format_files_grouped_by_bucket", + response: &models.BucketsFilesSearchResponse{ + Credits: models.CreditsInfo{ + Unlimited: true, + }, + Results: map[string]any{ + "files": []any{ + map[string]any{ + "bucket": "test-bucket.s3.amazonaws.com", + "file": "documents/report.pdf", + "url": "https://test-bucket.s3.amazonaws.com/documents/report.pdf", + "size": float64(1024000), + "lastModified": "2024-01-01T00:00:00Z", + }, + map[string]any{ + "bucket": "test-bucket.s3.amazonaws.com", + "file": "images/logo.png", + "url": "https://test-bucket.s3.amazonaws.com/images/logo.png", + "size": float64(50000), + }, + map[string]any{ + "bucket": "another-bucket.s3.amazonaws.com", + "file": "data.json", + "url": "https://another-bucket.s3.amazonaws.com/data.json", + }, + }, + }, + }, + asJSON: false, + wantErr: false, + }, + { + name: "format_empty_files", + response: &models.BucketsFilesSearchResponse{ + Credits: models.CreditsInfo{ + Remaining: 50, + Unlimited: false, + }, + Results: map[string]any{ + "files": []any{}, + }, + }, + asJSON: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := FormatBucketFilesResults(tt.response, tt.asJSON) + + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + os.Stdout = oldStdout + + if (err != nil) != tt.wantErr { + t.Errorf("FormatBucketFilesResults() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestGetBucketTypeHeader(t *testing.T) { + tests := []struct { + name string + bucketType string + wantNonEmpty bool + }{ + { + name: "aws_header", + bucketType: "aws", + wantNonEmpty: true, + }, + { + name: "gcp_header", + bucketType: "gcp", + wantNonEmpty: true, + }, + { + name: "dos_header", + bucketType: "dos", + wantNonEmpty: true, + }, + { + name: "unknown_header", + bucketType: "custom", + wantNonEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getBucketTypeHeader(tt.bucketType) + if tt.wantNonEmpty && got == "" { + t.Errorf("getBucketTypeHeader() returned empty string for %s", tt.bucketType) + } + }) + } +} + +func TestFormatFileCount(t *testing.T) { + tests := []struct { + name string + count int + want bool + }{ + { + name: "small_count", + count: 10, + want: true, + }, + { + name: "medium_count", + count: 500, + want: true, + }, + { + name: "large_count", + count: 5000, + want: true, + }, + { + name: "very_large_count", + count: 50000, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatFileCount(tt.count) + if tt.want && got == "" { + t.Errorf("formatFileCount() returned empty for count %d", tt.count) + } + }) + } +} + +func TestFormatNumber(t *testing.T) { + tests := []struct { + name string + n int + want string + }{ + { + name: "small_number", + n: 999, + want: "999", + }, + { + name: "thousand", + n: 1000, + want: "1,000", + }, + { + name: "million", + n: 1000000, + want: "1,000,000", + }, + { + name: "random_large", + n: 12345678, + want: "12,345,678", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatNumber(tt.n) + if got != tt.want { + t.Errorf("formatNumber() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetExtensionColor(t *testing.T) { + tests := []struct { + name string + ext string + want string + }{ + { + name: "pdf_extension", + ext: "pdf", + want: ColorBlue, + }, + { + name: "image_extension", + ext: "png", + want: ColorGreen, + }, + { + name: "video_extension", + ext: "mp4", + want: ColorMagenta, + }, + { + name: "archive_extension", + ext: "zip", + want: ColorYellow, + }, + { + name: "database_extension", + ext: "sql", + want: ColorRed, + }, + { + name: "config_extension", + ext: "json", + want: ColorCyan, + }, + { + name: "unknown_extension", + ext: "xyz", + want: ColorWhite, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getExtensionColor(tt.ext) + if got != tt.want { + t.Errorf("getExtensionColor() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTruncateBucketName(t *testing.T) { + tests := []struct { + name string + bucket string + wantLen int + }{ + { + name: "short_name", + bucket: "test-bucket", + wantLen: 50, + }, + { + name: "long_aws_bucket", + bucket: "very-long-bucket-name-that-exceeds-the-limit.s3.amazonaws.com", + wantLen: 60, + }, + { + name: "long_gcp_bucket", + bucket: "extremely-long-google-cloud-storage-bucket-name.storage.googleapis.com", + wantLen: 80, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateBucketName(tt.bucket) + if len(got) > tt.wantLen { + t.Errorf("truncateBucketName() length = %d, want <= %d", len(got), tt.wantLen) + } + }) + } +} + +func TestExtractFileNameFromURL(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "simple_filename", + url: "https://bucket.s3.amazonaws.com/file.pdf", + want: "file.pdf", + }, + { + name: "filename_with_path", + url: "https://bucket.s3.amazonaws.com/path/to/document.docx", + want: "document.docx", + }, + { + name: "filename_with_query", + url: "https://bucket.s3.amazonaws.com/image.jpg?version=123", + want: "image.jpg", + }, + { + name: "encoded_filename", + url: "https://bucket.s3.amazonaws.com/my%20file%20name.pdf", + want: "my%20file%20name.pdf", + }, + { + name: "no_filename", + url: "https://bucket.s3.amazonaws.com/", + want: "index", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractFileNameFromURL(tt.url) + if got != tt.want { + t.Errorf("extractFileNameFromURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractBucketFromURL(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + { + name: "aws_s3_url", + url: "https://my-bucket.s3.amazonaws.com/file.pdf", + want: "my-bucket.s3.amazonaws.com", + }, + { + name: "digitalocean_spaces", + url: "https://space.nyc3.digitaloceanspaces.com/path/file.jpg", + want: "space.nyc3.digitaloceanspaces.com", + }, + { + name: "gcp_storage", + url: "https://bucket.storage.googleapis.com/data.json", + want: "bucket.storage.googleapis.com", + }, + { + name: "http_url", + url: "http://bucket.example.com/file.txt", + want: "bucket.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractBucketFromURL(tt.url) + if got != tt.want { + t.Errorf("extractBucketFromURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDecodeURLEncoding(t *testing.T) { + tests := []struct { + name string + s string + want string + }{ + { + name: "spaces", + s: "my%20file%20name.pdf", + want: "my file name.pdf", + }, + { + name: "parentheses", + s: "document%28final%29.docx", + want: "document(final).docx", + }, + { + name: "underscores", + s: "test_file_name.jpg", + want: "test file name.jpg", + }, + { + name: "mixed", + s: "WhatsApp%20Image_2021%2810%29.jpeg", + want: "WhatsApp Image 2021(10).jpeg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := decodeURLEncoding(tt.s) + if got != tt.want { + t.Errorf("decodeURLEncoding() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/formatter/dns.go b/internal/formatter/dns.go new file mode 100644 index 0000000..6abd5b0 --- /dev/null +++ b/internal/formatter/dns.go @@ -0,0 +1,57 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "strings" + + "git.db.org.ai/dborg/internal/models" +) + +func FormatDNSResults(result *models.DomainResult, asJSON bool) (string, error) { + if asJSON { + data, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var sb strings.Builder + + sb.WriteString("\n") + + domainColor := result.Domain + if result.Status == "ACTIVE" { + domainColor = Green(result.Domain) + } else if result.Status == "INACTIVE" { + domainColor = Red(result.Domain) + } else { + domainColor = Yellow(result.Domain) + } + + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Domain:"), Bold(domainColor))) + + statusIndicator := StatusPending + if result.Status == "ACTIVE" { + statusIndicator = StatusSuccess + } else if result.Status == "INACTIVE" { + statusIndicator = StatusError + } else { + statusIndicator = StatusWarning + } + sb.WriteString(fmt.Sprintf("%s %s %s\n", Cyan("Status:"), statusIndicator.String(), result.Status)) + + if result.Title != "" { + sb.WriteString(fmt.Sprintf("%s %s\n", Cyan("Title:"), result.Title)) + } + + if len(result.Tech) > 0 { + sb.WriteString(fmt.Sprintf("%s\n", Cyan("Tech Stack:"))) + for _, tech := range result.Tech { + sb.WriteString(fmt.Sprintf(" %s %s\n", Dim("•"), Green(tech))) + } + } + + return sb.String(), nil +} diff --git a/internal/formatter/files.go b/internal/formatter/files.go new file mode 100644 index 0000000..7dedb53 --- /dev/null +++ b/internal/formatter/files.go @@ -0,0 +1,79 @@ +package formatter + +import ( + "fmt" + "strings" + + "git.db.org.ai/dborg/internal/models" + "git.db.org.ai/dborg/internal/utils" +) + +func FormatFilesResults(response models.OpenDirectorySearchResponse, asJSON bool) error { + if asJSON { + return utils.PrintJSON(response) + } + + PrintSection("Open Directory File Search Results") + + if len(response) == 0 { + PrintWarning("No results found") + return nil + } + + hits, ok := response["hits"].(map[string]interface{}) + if !ok { + PrintWarning("Invalid response format") + return nil + } + + total, _ := hits["total"].(map[string]interface{}) + totalValue := int64(0) + if val, ok := total["value"].(float64); ok { + totalValue = int64(val) + } + + took := int64(0) + if val, ok := response["took"].(float64); ok { + took = int64(val) + } + + fmt.Printf("Total results: %s%d%s (showing first results)\n", ColorCyan, totalValue, ColorReset) + fmt.Printf("Query time: %s%dms%s\n\n", ColorGray, took, ColorReset) + + hitsArray, ok := hits["hits"].([]interface{}) + if !ok || len(hitsArray) == 0 { + PrintWarning("No files found") + return nil + } + + for i, hit := range hitsArray { + hitMap, ok := hit.(map[string]interface{}) + if !ok { + continue + } + + source, ok := hitMap["_source"].(map[string]interface{}) + if !ok { + continue + } + + filename, _ := source["filename"].(string) + extension, _ := source["extension"].(string) + url, _ := source["url"].(string) + score, _ := hitMap["_score"].(float64) + + fmt.Printf("%s[%d]%s %s%s%s\n", ColorGray, i+1, ColorReset, ColorYellow, filename, ColorReset) + fmt.Printf(" Extension: %s%s%s\n", ColorCyan, extension, ColorReset) + fmt.Printf(" Score: %s%.2f%s\n", ColorGray, score, ColorReset) + + displayURL := strings.ReplaceAll(url, "\u0026", "&") + + fmt.Printf(" URL: %s%s%s\n", ColorBlue, displayURL, ColorReset) + + if i < len(hitsArray)-1 { + fmt.Println() + } + } + + return nil +} diff --git a/internal/formatter/formatter.go b/internal/formatter/formatter.go new file mode 100644 index 0000000..4d65c60 --- /dev/null +++ b/internal/formatter/formatter.go @@ -0,0 +1,478 @@ +package formatter + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "git.db.org.ai/dborg/internal/utils" +) + +const ( + ColorReset = "\033[0m" + ColorRed = "\033[31m" + ColorGreen = "\033[32m" + ColorYellow = "\033[33m" + ColorBlue = "\033[34m" + ColorMagenta = "\033[35m" + ColorCyan = "\033[36m" + ColorGray = "\033[90m" + ColorWhite = "\033[37m" + ColorBold = "\033[1m" + ColorDim = "\033[2m" +) + +type Formatter interface { + Format(data any) (string, error) + Print(data any) error +} + +type OutputMode int + +const ( + ModeJSON OutputMode = iota + ModePretty +) + +type BaseFormatter struct { + mode OutputMode + writer io.Writer +} + +func NewFormatter(isJSON bool) *BaseFormatter { + mode := ModePretty + if isJSON { + mode = ModeJSON + } + return &BaseFormatter{ + mode: mode, + writer: os.Stdout, + } +} + +func (f *BaseFormatter) IsJSON() bool { + return f.mode == ModeJSON +} + +func (f *BaseFormatter) FormatJSON(data any) error { + return utils.PrintJSON(data) +} + +func isTerminal() bool { + fileInfo, err := os.Stdout.Stat() + if err != nil { + return false + } + return (fileInfo.Mode() & os.ModeCharDevice) != 0 +} + +func GetTerminalWidth() int { + return 80 +} + +func Colorize(text string, color string) string { + if !isTerminal() { + return text + } + return color + text + ColorReset +} + +func Bold(text string) string { + return Colorize(text, ColorBold) +} + +func Dim(text string) string { + return Colorize(text, ColorDim) +} + +func Red(text string) string { + return Colorize(text, ColorRed) +} + +func Green(text string) string { + return Colorize(text, ColorGreen) +} + +func Yellow(text string) string { + return Colorize(text, ColorYellow) +} + +func Blue(text string) string { + return Colorize(text, ColorBlue) +} + +func Cyan(text string) string { + return Colorize(text, ColorCyan) +} + +func Gray(text string) string { + return Colorize(text, ColorGray) +} + +func Magenta(text string) string { + return Colorize(text, ColorMagenta) +} + +type CreditsDisplay struct { + Current int64 + Used int64 + Remaining int64 + Operation string +} + +func FormatCredits(credits int64) string { + var color string + switch { + case credits > 1000: + color = ColorGreen + case credits > 100: + color = ColorYellow + default: + color = ColorRed + } + return Colorize(fmt.Sprintf("%d", credits), color) +} + +func FormatCreditsWithLabel(credits int64, label string) string { + return fmt.Sprintf("%s: %s", Dim(label), FormatCredits(credits)) +} + +func PrintCreditsInfo(display *CreditsDisplay) { + if display == nil { + return + } + + fmt.Printf("%s\n", Bold("Credits Information")) + if display.Operation != "" { + fmt.Printf(" %s: %s\n", Dim("Operation"), display.Operation) + } + if display.Current > 0 { + fmt.Printf(" %s\n", FormatCreditsWithLabel(display.Current, "Current")) + } + if display.Used > 0 { + fmt.Printf(" %s\n", FormatCreditsWithLabel(display.Used, "Used")) + } + if display.Remaining > 0 { + fmt.Printf(" %s\n", FormatCreditsWithLabel(display.Remaining, "Remaining")) + } + fmt.Println() +} + +type ProgressBar struct { + Total int + Current int + Width int + Label string +} + +func NewProgressBar(total int, label string) *ProgressBar { + return &ProgressBar{ + Total: total, + Width: 40, + Label: label, + } +} + +func (p *ProgressBar) Update(current int) { + p.Current = current +} + +func (p *ProgressBar) Render() string { + if p.Total <= 0 { + return "" + } + + percentage := float64(p.Current) / float64(p.Total) + filled := int(percentage * float64(p.Width)) + + if filled > p.Width { + filled = p.Width + } + + bar := strings.Repeat("█", filled) + strings.Repeat("░", p.Width-filled) + percentStr := fmt.Sprintf("%.1f%%", percentage*100) + + var barColor string + switch { + case percentage >= 1.0: + barColor = ColorGreen + case percentage >= 0.5: + barColor = ColorYellow + default: + barColor = ColorCyan + } + + coloredBar := Colorize(bar, barColor) + + if p.Label != "" { + return fmt.Sprintf("%s [%s] %s (%d/%d)", + Dim(p.Label), coloredBar, Bold(percentStr), p.Current, p.Total) + } + + return fmt.Sprintf("[%s] %s (%d/%d)", + coloredBar, Bold(percentStr), p.Current, p.Total) +} + +func (p *ProgressBar) Print() { + fmt.Printf("\r%s", p.Render()) +} + +func (p *ProgressBar) Finish() { + p.Current = p.Total + fmt.Printf("\r%s\n", p.Render()) +} + +type TableFormatter struct { + headers []string + rows [][]string + writer *tabwriter.Writer +} + +func NewTable(headers []string) *TableFormatter { + return &TableFormatter{ + headers: headers, + rows: make([][]string, 0), + writer: tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0), + } +} + +func (t *TableFormatter) AddRow(columns ...string) { + t.rows = append(t.rows, columns) +} + +func (t *TableFormatter) AddRows(rows [][]string) { + t.rows = append(t.rows, rows...) +} + +func (t *TableFormatter) Render() string { + var buf bytes.Buffer + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + + for i, h := range t.headers { + if i > 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, Bold(h)) + } + fmt.Fprintln(w) + + for _, row := range t.rows { + for i, col := range row { + if i > 0 { + fmt.Fprint(w, "\t") + } + fmt.Fprint(w, col) + } + fmt.Fprintln(w) + } + + w.Flush() + return buf.String() +} + +func (t *TableFormatter) Print() { + fmt.Print(t.Render()) +} + +func (t *TableFormatter) PrintJSON() error { + data := make([]map[string]string, 0, len(t.rows)) + + for _, row := range t.rows { + rowMap := make(map[string]string) + for i, col := range row { + if i < len(t.headers) { + rowMap[t.headers[i]] = col + } + } + data = append(data, rowMap) + } + + return utils.PrintJSON(data) +} + +type KeyValue struct { + Key string + Value string +} + +func FormatKeyValue(key, value string) string { + return fmt.Sprintf("%s: %s", Cyan(key), value) +} + +func FormatKeyValueList(items []KeyValue) string { + var buf bytes.Buffer + for i, item := range items { + if i > 0 { + buf.WriteString("\n") + } + buf.WriteString(FormatKeyValue(item.Key, item.Value)) + } + return buf.String() +} + +func PrintKeyValue(key, value string) { + fmt.Println(FormatKeyValue(key, value)) +} + +func PrintSection(title string) { + fmt.Printf("\n%s\n%s\n", Bold(title), strings.Repeat("─", len(title))) +} + +func PrintDivider() { + if isTerminal() { + fmt.Println(Dim(strings.Repeat("─", 80))) + } else { + fmt.Println(strings.Repeat("-", 80)) + } +} + +type StatusIndicator int + +const ( + StatusSuccess StatusIndicator = iota + StatusWarning + StatusError + StatusInfo + StatusPending +) + +func (s StatusIndicator) String() string { + switch s { + case StatusSuccess: + return Green("✓") + case StatusWarning: + return Yellow("⚠") + case StatusError: + return Red("✗") + case StatusInfo: + return Blue("ℹ") + case StatusPending: + return Gray("●") + default: + return "?" + } +} + +func FormatStatus(status StatusIndicator, message string) string { + return fmt.Sprintf("%s %s", status.String(), message) +} + +func PrintStatus(status StatusIndicator, message string) { + fmt.Println(FormatStatus(status, message)) +} + +func PrintSuccess(message string) { + PrintStatus(StatusSuccess, message) +} + +func PrintWarning(message string) { + PrintStatus(StatusWarning, message) +} + +func PrintError(message string) { + PrintStatus(StatusError, message) +} + +func PrintInfo(message string) { + PrintStatus(StatusInfo, message) +} + +func FormatBytes(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +func FormatPercentage(value, total int64) string { + if total == 0 { + return "0.0%" + } + percentage := float64(value) / float64(total) * 100 + return fmt.Sprintf("%.1f%%", percentage) +} + +func TruncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +func PadRight(s string, width int) string { + if len(s) >= width { + return s + } + return s + strings.Repeat(" ", width-len(s)) +} + +func PadLeft(s string, width int) string { + if len(s) >= width { + return s + } + return strings.Repeat(" ", width-len(s)) + s +} + +func FormatList(items []string, bullet string) string { + if bullet == "" { + bullet = "•" + } + var buf bytes.Buffer + for i, item := range items { + if i > 0 { + buf.WriteString("\n") + } + buf.WriteString(fmt.Sprintf("%s %s", Dim(bullet), item)) + } + return buf.String() +} + +func PrintList(items []string) { + fmt.Println(FormatList(items, "•")) +} + +func StreamJSON(w io.Writer, data any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(data) +} + +func StreamNDJSON(w io.Writer, data any) error { + encoder := json.NewEncoder(w) + return encoder.Encode(data) +} + +func PrintColorizedJSON(data any) error { + return utils.PrintJSON(data) +} + +func FormatColorizedJSON(data any) (string, error) { + output, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("failed to format JSON: %w", err) + } + return utils.ColorizeJSON(output), nil +} + +func PrintJSONWithHeader(header string, data any) error { + fmt.Fprintf(os.Stderr, "\n%s\n", Bold(header)) + output, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to format JSON: %w", err) + } + fmt.Println(utils.ColorizeJSON(output)) + return nil +} diff --git a/internal/formatter/geo.go b/internal/formatter/geo.go new file mode 100644 index 0000000..8b95899 --- /dev/null +++ b/internal/formatter/geo.go @@ -0,0 +1,10 @@ +package formatter + +import ( + "git.db.org.ai/dborg/internal/models" + "git.db.org.ai/dborg/internal/utils" +) + +func FormatGeoResults(response models.GeoSearchResponse, asJSON bool) error { + return utils.PrintJSON(response) +} diff --git a/internal/formatter/npd.go b/internal/formatter/npd.go new file mode 100644 index 0000000..0d15d7b --- /dev/null +++ b/internal/formatter/npd.go @@ -0,0 +1,139 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "strings" + + "git.db.org.ai/dborg/internal/models" +) + +func FormatNPDResults(resp *models.NPDResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(resp.Results.Hits, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan("NPD Breach Data Search Results")))) + sb.WriteString(fmt.Sprintf("%s\n\n", Gray(strings.Repeat("─", 50)))) + + sb.WriteString(fmt.Sprintf("%s %s / %s max\n", + Blue("Total Hits:"), + Yellow(fmt.Sprintf("%d", resp.Results.NumHits)), + Dim(fmt.Sprintf("%d", resp.MaxHits)))) + sb.WriteString(fmt.Sprintf("%s %s\n\n", + Blue("Query Time:"), + Dim(fmt.Sprintf("%d microseconds", resp.Results.ElapsedTimeMicros)))) + + if len(resp.Results.Errors) > 0 { + sb.WriteString(fmt.Sprintf("%s\n", Red("Errors:"))) + for _, err := range resp.Results.Errors { + sb.WriteString(fmt.Sprintf(" %s %s\n", Red("✗"), err)) + } + sb.WriteString("\n") + } + + if len(resp.Results.Hits) > 0 { + sb.WriteString(fmt.Sprintf("%s\n", Bold("Records:"))) + sb.WriteString(fmt.Sprintf("%s\n", Gray(strings.Repeat("─", 50)))) + + for i, hit := range resp.Results.Hits { + sb.WriteString(fmt.Sprintf("\n%s:\n", Bold(Blue(fmt.Sprintf("Record %d", i+1))))) + + if name := getStringField(hit, "firstname", "lastname", "middlename"); name != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Name:"), Bold(name))) + } + + if dob := getStringField(hit, "dob"); dob != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("DOB:"), dob)) + } + + if ssn := getStringField(hit, "ssn"); ssn != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("SSN:"), Yellow(ssn))) + } + + if phone := getStringField(hit, "phone1"); phone != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Phone:"), phone)) + } + + if addr := buildAddress(hit); addr != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Address:"), addr)) + } + + for k, v := range hit { + if !isCommonField(k) && v != nil && v != "" { + sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("%s:", k)), v)) + } + } + } + } else { + sb.WriteString(fmt.Sprintf("%s\n", Gray("No records found"))) + } + + sb.WriteString("\n") + if resp.Credits.Unlimited { + sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits"), Green("Unlimited"))) + } else { + sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(resp.Credits.Remaining)))) + } + + return sb.String(), nil +} + +func getStringField(data map[string]any, keys ...string) string { + var parts []string + for _, key := range keys { + if val, ok := data[key]; ok && val != nil { + if str, ok := val.(string); ok && str != "" { + parts = append(parts, str) + } + } + } + return strings.Join(parts, " ") +} + +func buildAddress(data map[string]any) string { + var parts []string + + if addr, ok := data["address"].(string); ok && addr != "" { + parts = append(parts, addr) + } + + cityStateZip := []string{} + if city, ok := data["city"].(string); ok && city != "" { + cityStateZip = append(cityStateZip, city) + } + if state, ok := data["st"].(string); ok && state != "" { + cityStateZip = append(cityStateZip, state) + } + if zip, ok := data["zip"].(string); ok && zip != "" { + cityStateZip = append(cityStateZip, zip) + } + + if len(cityStateZip) > 0 { + parts = append(parts, strings.Join(cityStateZip, ", ")) + } + + return strings.Join(parts, ", ") +} + +func isCommonField(field string) bool { + commonFields := map[string]bool{ + "firstname": true, + "lastname": true, + "middlename": true, + "dob": true, + "ssn": true, + "phone1": true, + "address": true, + "city": true, + "st": true, + "zip": true, + } + return commonFields[field] +} diff --git a/internal/formatter/reddit.go b/internal/formatter/reddit.go new file mode 100644 index 0000000..a696deb --- /dev/null +++ b/internal/formatter/reddit.go @@ -0,0 +1,444 @@ +package formatter + +import ( + "bytes" + "encoding/json" + "fmt" + "html" + "strings" + "text/tabwriter" + "time" + + "git.db.org.ai/dborg/internal/models" +) + +func FormatRedditResults(resp interface{}, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var credits models.RedditCredits + var resultsData interface{} + var dataType string + var identifier string + + switch r := resp.(type) { + case *models.SubredditResponse: + credits = r.Credits + resultsData = r.Results + dataType = r.Type + identifier = r.Subreddit + case *models.UserResponse: + credits = r.Credits + resultsData = r.Results + dataType = r.Type + identifier = r.Username + default: + return "", fmt.Errorf("unsupported response type") + } + + var buf bytes.Buffer + + switch dataType { + case "comments": + formatRedditComments(&buf, resultsData, dataType, identifier) + case "posts": + formatRedditPosts(&buf, resultsData, dataType, identifier) + case "about": + formatRedditUserAbout(&buf, resultsData, dataType, identifier) + default: + buf.WriteString(fmt.Sprintf("\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit %s - %s", dataType, identifier))))) + buf.WriteString(fmt.Sprintf("%s\n\n", Gray(strings.Repeat("─", 80)))) + + if resultsData != nil { + resultsJSON, err := json.MarshalIndent(resultsData, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal results: %w", err) + } + buf.WriteString(string(resultsJSON)) + buf.WriteString("\n\n") + } else { + buf.WriteString(fmt.Sprintf("%s\n\n", Gray("No results found"))) + } + } + + if credits.Unlimited { + buf.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits"), Green("Unlimited"))) + } else { + buf.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(credits.Remaining)))) + } + + return buf.String(), nil +} + +func formatRedditUserAbout(buf *bytes.Buffer, resultsData interface{}, dataType, identifier string) { + fmt.Fprintf(buf, "\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit User - u/%s", identifier)))) + fmt.Fprintf(buf, "%s\n\n", Gray(strings.Repeat("─", 80))) + + userData, ok := resultsData.(map[string]interface{}) + if !ok { + fmt.Fprintf(buf, "%s\n", Gray("Invalid data structure")) + return + } + + data, ok := userData["data"].(map[string]interface{}) + if !ok { + fmt.Fprintf(buf, "%s\n", Gray("No user data found")) + return + } + + w := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + + if name := getStringValue(data, "name"); name != "" { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Username:"), Bold(fmt.Sprintf("u/%s", name))) + } + + if id := getStringValue(data, "id"); id != "" { + fmt.Fprintf(w, "%s\t%s\n", Cyan("User ID:"), Dim(id)) + } + + if created, ok := data["created_utc"].(float64); ok { + timestamp := time.Unix(int64(created), 0) + age := time.Since(timestamp) + years := int(age.Hours() / 24 / 365) + fmt.Fprintf(w, "%s\t%s (%s)\n", + Cyan("Account Age:"), + Gray(timestamp.Format("2006-01-02")), + Yellow(fmt.Sprintf("%d years", years))) + } + + totalKarma := getIntValue(data, "total_karma") + linkKarma := getIntValue(data, "link_karma") + commentKarma := getIntValue(data, "comment_karma") + awardeeKarma := getIntValue(data, "awardee_karma") + awarderKarma := getIntValue(data, "awarder_karma") + + fmt.Fprintf(w, "%s\t%s\n", Cyan("Total Karma:"), Bold(Green(fmt.Sprintf("%d", totalKarma)))) + fmt.Fprintf(w, "%s\t%s\n", Cyan(" Post Karma:"), Green(fmt.Sprintf("%d", linkKarma))) + fmt.Fprintf(w, "%s\t%s\n", Cyan(" Comment Karma:"), Green(fmt.Sprintf("%d", commentKarma))) + if awardeeKarma > 0 { + fmt.Fprintf(w, "%s\t%s\n", Cyan(" Awardee Karma:"), Green(fmt.Sprintf("%d", awardeeKarma))) + } + if awarderKarma > 0 { + fmt.Fprintf(w, "%s\t%s\n", Cyan(" Awarder Karma:"), Green(fmt.Sprintf("%d", awarderKarma))) + } + + badges := []string{} + if isMod, ok := data["is_mod"].(bool); ok && isMod { + badges = append(badges, Green("Moderator")) + } + if isEmployee, ok := data["is_employee"].(bool); ok && isEmployee { + badges = append(badges, Red("Reddit Employee")) + } + if isGold, ok := data["is_gold"].(bool); ok && isGold { + badges = append(badges, Yellow("Premium")) + } + if verified, ok := data["verified"].(bool); ok && verified { + badges = append(badges, Blue("Verified Email")) + } + + if len(badges) > 0 { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Badges:"), strings.Join(badges, " • ")) + } + + if acceptFollowers, ok := data["accept_followers"].(bool); ok { + if acceptFollowers { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Followers:"), Green("Enabled")) + } else { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Followers:"), Gray("Disabled")) + } + } + + w.Flush() + + if subreddit, ok := data["subreddit"].(map[string]interface{}); ok { + if publicDesc := getStringValue(subreddit, "public_description"); publicDesc != "" { + fmt.Fprintf(buf, "\n%s\n", Cyan("Bio:")) + publicDesc = html.UnescapeString(publicDesc) + lines := strings.Split(publicDesc, "\n") + for _, line := range lines { + if len(line) > 78 { + wrapped := wrapText(line, 76) + for _, wLine := range wrapped { + fmt.Fprintf(buf, " %s\n", wLine) + } + } else { + fmt.Fprintf(buf, " %s\n", line) + } + } + } + } + + if iconImg := getStringValue(data, "icon_img"); iconImg != "" { + fmt.Fprintf(buf, "\n%s %s\n", Cyan("Avatar:"), Blue(iconImg)) + } + + fmt.Fprintf(buf, "\n") +} + +func formatRedditPosts(buf *bytes.Buffer, resultsData interface{}, dataType, identifier string) { + fmt.Fprintf(buf, "\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit %s - %s", dataType, identifier)))) + fmt.Fprintf(buf, "%s\n\n", Gray(strings.Repeat("─", 80))) + + listing, ok := resultsData.(map[string]interface{}) + if !ok { + fmt.Fprintf(buf, "%s\n", Gray("Invalid data structure")) + return + } + + data, ok := listing["data"].(map[string]interface{}) + if !ok { + fmt.Fprintf(buf, "%s\n", Gray("No data found")) + return + } + + children, ok := data["children"].([]interface{}) + if !ok || len(children) == 0 { + fmt.Fprintf(buf, "%s\n", Gray("No posts found")) + return + } + + for i, child := range children { + childMap, ok := child.(map[string]interface{}) + if !ok { + continue + } + + postData, ok := childMap["data"].(map[string]interface{}) + if !ok { + continue + } + + formatSinglePost(buf, postData, i+1) + } +} + +func formatRedditComments(buf *bytes.Buffer, resultsData interface{}, dataType, identifier string) { + fmt.Fprintf(buf, "\n%s\n", Bold(Cyan(fmt.Sprintf("Reddit %s - %s", dataType, identifier)))) + fmt.Fprintf(buf, "%s\n\n", Gray(strings.Repeat("─", 80))) + + listing, ok := resultsData.(map[string]interface{}) + if !ok { + fmt.Fprintf(buf, "%s\n", Gray("Invalid data structure")) + return + } + + data, ok := listing["data"].(map[string]interface{}) + if !ok { + fmt.Fprintf(buf, "%s\n", Gray("No data found")) + return + } + + children, ok := data["children"].([]interface{}) + if !ok || len(children) == 0 { + fmt.Fprintf(buf, "%s\n", Gray("No comments found")) + return + } + + for i, child := range children { + childMap, ok := child.(map[string]interface{}) + if !ok { + continue + } + + commentData, ok := childMap["data"].(map[string]interface{}) + if !ok { + continue + } + + formatSingleComment(buf, commentData, i+1) + } +} + +func formatSinglePost(buf *bytes.Buffer, post map[string]interface{}, num int) { + fmt.Fprintf(buf, "%s\n", Bold(Yellow(fmt.Sprintf("Post #%d", num)))) + + title := getStringValue(post, "title") + if title != "" { + title = html.UnescapeString(title) + fmt.Fprintf(buf, "%s\n", Bold(Green(title))) + } + + w := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + + author := getStringValue(post, "author") + subreddit := getStringValue(post, "subreddit") + if author != "" { + fmt.Fprintf(w, "%s\t%s", Cyan("Author:"), Bold(fmt.Sprintf("u/%s", author))) + if subreddit != "" { + fmt.Fprintf(w, " in %s", Bold(fmt.Sprintf("r/%s", subreddit))) + } + fmt.Fprintf(w, "\n") + } + + if created, ok := post["created_utc"].(float64); ok { + timestamp := time.Unix(int64(created), 0) + fmt.Fprintf(w, "%s\t%s\n", Cyan("Posted:"), Gray(timestamp.Format("2006-01-02 15:04:05 MST"))) + } + + score := getIntValue(post, "score") + upvoteRatio := 0.0 + if ratio, ok := post["upvote_ratio"].(float64); ok { + upvoteRatio = ratio + } + fmt.Fprintf(w, "%s\t%s", Cyan("Score:"), formatScore(score)) + if upvoteRatio > 0 { + fmt.Fprintf(w, " (%s upvoted)", Dim(fmt.Sprintf("%.0f%%", upvoteRatio*100))) + } + fmt.Fprintf(w, "\n") + + numComments := getIntValue(post, "num_comments") + fmt.Fprintf(w, "%s\t%s\n", Cyan("Comments:"), Yellow(fmt.Sprintf("%d", numComments))) + + if stickied, ok := post["stickied"].(bool); ok && stickied { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Status:"), Green("Stickied")) + } + + if linkFlair := getStringValue(post, "link_flair_text"); linkFlair != "" { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Flair:"), Magenta(linkFlair)) + } + + domain := getStringValue(post, "domain") + url := getStringValue(post, "url") + isSelf, _ := post["is_self"].(bool) + + if !isSelf && url != "" { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Link:"), Blue(url)) + if domain != "" && !strings.HasPrefix(domain, "self.") { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Domain:"), Dim(domain)) + } + } + + if permalink := getStringValue(post, "permalink"); permalink != "" { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Reddit:"), Blue(fmt.Sprintf("https://reddit.com%s", permalink))) + } + + w.Flush() + + selftext := getStringValue(post, "selftext") + if selftext != "" && selftext != "[removed]" && selftext != "[deleted]" { + fmt.Fprintf(buf, "\n%s\n", Cyan("Content:")) + selftext = html.UnescapeString(selftext) + + lines := strings.Split(selftext, "\n") + displayedLines := 0 + maxLines := 15 + + for _, line := range lines { + if displayedLines >= maxLines { + remaining := len(lines) - displayedLines + fmt.Fprintf(buf, " %s\n", Dim(fmt.Sprintf("... (%d more lines)", remaining))) + break + } + + if len(line) > 80 { + wrapped := wrapText(line, 78) + for _, wLine := range wrapped { + if displayedLines >= maxLines { + break + } + fmt.Fprintf(buf, " %s\n", wLine) + displayedLines++ + } + } else { + fmt.Fprintf(buf, " %s\n", line) + displayedLines++ + } + } + } + + if edited := post["edited"]; edited != nil && edited != false { + fmt.Fprintf(buf, "\n%s\n", Dim("(edited)")) + } + + fmt.Fprintf(buf, "\n%s\n\n", Gray(strings.Repeat("─", 80))) +} + +func formatSingleComment(buf *bytes.Buffer, comment map[string]interface{}, num int) { + w := tabwriter.NewWriter(buf, 0, 0, 1, ' ', 0) + + fmt.Fprintf(buf, "%s\n", Bold(Yellow(fmt.Sprintf("Comment #%d", num)))) + + author := getStringValue(comment, "author") + if author != "" { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Author:"), Bold(author)) + } + + subreddit := getStringValue(comment, "subreddit") + if subreddit != "" { + fmt.Fprintf(w, "%s\tr/%s\n", Cyan("Subreddit:"), subreddit) + } + + if created, ok := comment["created_utc"].(float64); ok { + timestamp := time.Unix(int64(created), 0) + fmt.Fprintf(w, "%s\t%s\n", Cyan("Posted:"), Gray(timestamp.Format("2006-01-02 15:04:05 MST"))) + } + + score := getIntValue(comment, "score") + fmt.Fprintf(w, "%s\t%s\n", Cyan("Score:"), formatScore(score)) + + if controversiality, ok := comment["controversiality"].(float64); ok && controversiality > 0 { + fmt.Fprintf(w, "%s\t%s\n", Cyan("Controversial:"), Yellow("Yes")) + } + + w.Flush() + + if linkTitle := getStringValue(comment, "link_title"); linkTitle != "" { + fmt.Fprintf(buf, "%s %s\n", Cyan("Post:"), Bold(linkTitle)) + } + + if permalink := getStringValue(comment, "permalink"); permalink != "" { + fmt.Fprintf(buf, "%s %s\n", Cyan("Link:"), Blue(fmt.Sprintf("https://reddit.com%s", permalink))) + } + + body := getStringValue(comment, "body") + if body != "" { + fmt.Fprintf(buf, "\n%s\n", Cyan("Comment:")) + body = html.UnescapeString(body) + + lines := strings.Split(body, "\n") + for _, line := range lines { + if len(line) > 80 { + wrapped := wrapText(line, 78) + for _, wLine := range wrapped { + fmt.Fprintf(buf, " %s\n", Green(wLine)) + } + } else { + fmt.Fprintf(buf, " %s\n", Green(line)) + } + } + } + + if edited := comment["edited"]; edited != nil && edited != false { + fmt.Fprintf(buf, "\n%s\n", Dim("(edited)")) + } + + fmt.Fprintf(buf, "\n%s\n\n", Gray(strings.Repeat("─", 80))) +} + +func getStringValue(data map[string]interface{}, key string) string { + if val, ok := data[key].(string); ok { + return val + } + return "" +} + +func getIntValue(data map[string]interface{}, key string) int { + if val, ok := data[key].(float64); ok { + return int(val) + } + return 0 +} + +func formatScore(score int) string { + if score > 0 { + return Green(fmt.Sprintf("+%d", score)) + } else if score < 0 { + return Red(fmt.Sprintf("%d", score)) + } + return Dim("0") +} diff --git a/internal/formatter/shortlinks.go b/internal/formatter/shortlinks.go new file mode 100644 index 0000000..d6073cb --- /dev/null +++ b/internal/formatter/shortlinks.go @@ -0,0 +1,80 @@ +package formatter + +import ( + "fmt" + "strings" + + "git.db.org.ai/dborg/internal/models" + "git.db.org.ai/dborg/internal/utils" +) + +func FormatShortlinksResults(response *models.ShortlinksSearchResponse, asJSON bool) error { + if asJSON { + return utils.PrintJSON(response) + } + + if response.Results == nil { + PrintWarning("No results found") + return nil + } + + resultsMap, ok := response.Results.(map[string]interface{}) + if !ok { + return utils.PrintJSON(response) + } + + hits, ok := resultsMap["hits"].([]interface{}) + if !ok || len(hits) == 0 { + PrintWarning("No results found") + return nil + } + + numHits, _ := resultsMap["num_hits"].(float64) + elapsed, _ := resultsMap["elapsed_time_micros"].(float64) + + fmt.Printf("%s %s %s\n", + Cyan("Found"), + Bold(Yellow(fmt.Sprintf("%d", int(numHits)))), + Cyan(fmt.Sprintf("credentials (%.2fms)", elapsed/1000))) + fmt.Println() + + for i, hit := range hits { + hitMap, ok := hit.(map[string]interface{}) + if !ok { + continue + } + + if i > 0 { + fmt.Println(Dim(strings.Repeat("─", 80))) + } + + username, _ := hitMap["username"].(string) + password, _ := hitMap["password"].(string) + url, _ := hitMap["url"].(string) + filename, _ := hitMap["filename"].(string) + + fmt.Printf("%s %s\n", Bold(Green("●")), Bold(username)) + + if password != "" { + fmt.Printf(" %s %s\n", Dim("Password:"), Yellow(password)) + } + + if url != "" { + fmt.Printf(" %s %s\n", Dim("URL:"), Blue(url)) + } + + if filename != "" { + fmt.Printf(" %s %s\n", Dim("Source:"), Magenta(filename)) + } + + fmt.Println() + } + + if response.Credits.Unlimited { + fmt.Printf("%s: %s\n", Dim("Credits"), Green("Unlimited")) + } else { + fmt.Printf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(response.Credits.Remaining))) + } + + return nil +} diff --git a/internal/formatter/skiptrace.go b/internal/formatter/skiptrace.go new file mode 100644 index 0000000..26c0a0f --- /dev/null +++ b/internal/formatter/skiptrace.go @@ -0,0 +1,128 @@ +package formatter + +import ( + "fmt" + "strings" + + "git.db.org.ai/dborg/internal/models" +) + +func FormatSkiptraceResults(resp interface{}, asJSON bool) (string, error) { + var data map[string]interface{} + + switch r := resp.(type) { + case *models.SkiptraceResponse: + data = r.Data + case *models.SkiptraceReportResponse: + data = r.Data + case *models.SkiptracePhoneResponse: + data = r.Data + case *models.SkiptraceEmailResponse: + data = r.Data + default: + return "", fmt.Errorf("unsupported skiptrace response type") + } + + err := PrintColorizedJSON(data) + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return "", nil +} + +func formatSkiptraceData(sb *strings.Builder, data map[string]interface{}) { + if person, ok := data["person"].(map[string]interface{}); ok { + sb.WriteString(fmt.Sprintf("%s\n", Bold("Person Information:"))) + formatPersonInfo(sb, person) + delete(data, "person") + } + + if phones, ok := data["phones"].([]interface{}); ok && len(phones) > 0 { + sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Phone Numbers:"))) + for _, phone := range phones { + if phoneMap, ok := phone.(map[string]interface{}); ok { + if number, ok := phoneMap["number"].(string); ok { + sb.WriteString(fmt.Sprintf(" %s %s", Dim("•"), Cyan(number))) + if carrier, ok := phoneMap["carrier"].(string); ok && carrier != "" { + sb.WriteString(fmt.Sprintf(" %s", Gray(fmt.Sprintf("(%s)", carrier)))) + } + if phoneType, ok := phoneMap["type"].(string); ok && phoneType != "" { + sb.WriteString(fmt.Sprintf(" %s", Dim(phoneType))) + } + sb.WriteString("\n") + } + } + } + delete(data, "phones") + } + + if emails, ok := data["emails"].([]interface{}); ok && len(emails) > 0 { + sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Email Addresses:"))) + for _, email := range emails { + if emailStr, ok := email.(string); ok { + sb.WriteString(fmt.Sprintf(" %s %s\n", Dim("•"), Blue(emailStr))) + } + } + delete(data, "emails") + } + + if addresses, ok := data["addresses"].([]interface{}); ok && len(addresses) > 0 { + sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Addresses:"))) + for _, addr := range addresses { + if addrMap, ok := addr.(map[string]interface{}); ok { + formatAddress(sb, addrMap) + } + } + delete(data, "addresses") + } + + if len(data) > 0 { + sb.WriteString(fmt.Sprintf("\n%s\n", Bold("Additional Information:"))) + if colorized, err := FormatColorizedJSON(data); err == nil { + lines := strings.Split(colorized, "\n") + for _, line := range lines { + sb.WriteString(fmt.Sprintf(" %s\n", line)) + } + } + } +} + +func formatPersonInfo(sb *strings.Builder, person map[string]interface{}) { + if name, ok := person["name"].(string); ok && name != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("Name:"), Bold(name))) + } + if age, ok := person["age"].(float64); ok && age > 0 { + sb.WriteString(fmt.Sprintf(" %s %d\n", Cyan("Age:"), int(age))) + } + if dob, ok := person["dob"].(string); ok && dob != "" { + sb.WriteString(fmt.Sprintf(" %s %s\n", Cyan("DOB:"), dob)) + } +} + +func formatAddress(sb *strings.Builder, addr map[string]interface{}) { + var addrStr strings.Builder + if street, ok := addr["street"].(string); ok && street != "" { + addrStr.WriteString(street) + } + if city, ok := addr["city"].(string); ok && city != "" { + if addrStr.Len() > 0 { + addrStr.WriteString(", ") + } + addrStr.WriteString(city) + } + if state, ok := addr["state"].(string); ok && state != "" { + if addrStr.Len() > 0 { + addrStr.WriteString(", ") + } + addrStr.WriteString(state) + } + if zip, ok := addr["zip"].(string); ok && zip != "" { + if addrStr.Len() > 0 { + addrStr.WriteString(" ") + } + addrStr.WriteString(zip) + } + if addrStr.Len() > 0 { + sb.WriteString(fmt.Sprintf(" %s %s\n", Dim("•"), addrStr.String())) + } +} diff --git a/internal/formatter/sl.go b/internal/formatter/sl.go new file mode 100644 index 0000000..d7d293f --- /dev/null +++ b/internal/formatter/sl.go @@ -0,0 +1,125 @@ +package formatter + +import ( + "encoding/json" + "fmt" + "strings" + + "git.db.org.ai/dborg/internal/models" +) + +func FormatSLResults(resp *models.SLResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(resp.Results, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var sb strings.Builder + + if resp.Results == nil { + sb.WriteString(fmt.Sprintf("\n%s\n\n", Gray("No results found"))) + return sb.String(), nil + } + + resultsMap, ok := resp.Results.(map[string]interface{}) + if !ok { + resultsJSON, err := json.MarshalIndent(resp.Results, "", " ") + if err != nil { + return "", fmt.Errorf("failed to format results: %w", err) + } + return string(resultsJSON), nil + } + + hits, ok := resultsMap["hits"].([]interface{}) + if !ok || len(hits) == 0 { + sb.WriteString(fmt.Sprintf("\n%s\n\n", Gray("No results found"))) + return sb.String(), nil + } + + numHits, _ := resultsMap["num_hits"].(float64) + elapsed, _ := resultsMap["elapsed_time_micros"].(float64) + + sb.WriteString(fmt.Sprintf("%s %s %s\n", + Cyan("Found"), + Bold(Yellow(fmt.Sprintf("%d", int(numHits)))), + Cyan(fmt.Sprintf("credentials (%.2fms)", elapsed/1000)))) + sb.WriteString("\n") + + for i, hit := range hits { + hitMap, ok := hit.(map[string]interface{}) + if !ok { + continue + } + + if i > 0 { + sb.WriteString(Dim("───")) + sb.WriteString("\n") + } + + username, _ := hitMap["username"].(string) + password, _ := hitMap["password"].(string) + url, _ := hitMap["url"].(string) + filename, _ := hitMap["filename"].(string) + + sb.WriteString(fmt.Sprintf("%s %s\n", Dim("Username:"), Bold(username))) + + if password != "" { + sb.WriteString(fmt.Sprintf("%s %s\n", Dim("Password:"), Yellow(password))) + } + + if url != "" { + sb.WriteString(fmt.Sprintf("%s %s\n", Dim("URL:"), Blue(url))) + } + + if filename != "" { + sb.WriteString(fmt.Sprintf("%s %s\n", Dim("Source:"), Magenta(filename))) + } + + sb.WriteString("\n") + } + + if resp.Credits.Unlimited { + sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits"), Green("Unlimited"))) + } else { + sb.WriteString(fmt.Sprintf("%s: %s\n", Dim("Credits Remaining"), FormatCredits(int64(resp.Credits.Remaining)))) + } + + return sb.String(), nil +} + +func formatSLResultsData(sb *strings.Builder, data map[string]interface{}) { + sb.WriteString(fmt.Sprintf("%s\n", Bold("Stealer Log Entries:"))) + + for key, value := range data { + sb.WriteString(fmt.Sprintf("\n %s:\n", Cyan(key))) + if valueMap, ok := value.(map[string]interface{}); ok { + for k, v := range valueMap { + sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("%s:", k)), v)) + } + } else if valueArray, ok := value.([]interface{}); ok { + for i, item := range valueArray { + sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("[%d]:", i)), item)) + } + } else { + sb.WriteString(fmt.Sprintf(" %v\n", value)) + } + } +} + +func formatSLResultsArray(sb *strings.Builder, data []interface{}) { + sb.WriteString(fmt.Sprintf("%s\n", Bold("Stealer Log Entries:"))) + + for i, entry := range data { + sb.WriteString(fmt.Sprintf("\n %s:\n", Blue(fmt.Sprintf("Entry %d", i+1)))) + if entryMap, ok := entry.(map[string]interface{}); ok { + for key, value := range entryMap { + sb.WriteString(fmt.Sprintf(" %s %v\n", Dim(fmt.Sprintf("%s:", key)), value)) + } + } else { + sb.WriteString(fmt.Sprintf(" %v\n", entry)) + } + } +} diff --git a/internal/formatter/username.go b/internal/formatter/username.go new file mode 100644 index 0000000..fc25487 --- /dev/null +++ b/internal/formatter/username.go @@ -0,0 +1,143 @@ +package formatter + +import ( + "fmt" + "strings" + + "git.db.org.ai/dborg/internal/models" + "git.db.org.ai/dborg/internal/utils" +) + +func FormatUsernameSiteResult(result *models.SiteResult) error { + if result.Status == "found" { + fmt.Printf("%s | %s", + Green("✓ FOUND"), + result.SiteName) + + if result.URL != "" { + fmt.Printf(" | %s", Blue(result.URL)) + } + + fmt.Println() + + if len(result.Metadata) > 0 { + fmt.Print(formatMetadata(result.Metadata)) + } + } + return nil +} + +func FormatUsernameResults(response *models.USRSXResponse, asJSON bool) error { + if asJSON { + return utils.PrintJSON(response) + } + + if response.Error != "" { + return fmt.Errorf("%s", response.Error) + } + + if response.Message != "" { + return nil + } + + if len(response.Results) == 0 { + return nil + } + + for _, result := range response.Results { + if result.Status == "found" { + fmt.Printf("%s | %s", + Green("✓ FOUND"), + result.SiteName) + + if result.URL != "" { + fmt.Printf(" | %s", Blue(result.URL)) + } + + fmt.Println() + + if len(result.Metadata) > 0 { + fmt.Print(formatMetadata(result.Metadata)) + } + } + } + + return nil +} + +func formatMetadata(metadata map[string]interface{}) string { + var lines []string + boxStyle := Dim(" ├─ ") + labelStyle := Dim + + if displayName, ok := metadata["display_name"].(string); ok && displayName != "" { + lines = append(lines, boxStyle+labelStyle("Name: ")+displayName) + } + + if bio, ok := metadata["bio"].(string); ok && bio != "" { + bioPreview := bio + if len(bioPreview) > 100 { + bioPreview = bioPreview[:97] + "..." + } + lines = append(lines, boxStyle+labelStyle("Bio: ")+bioPreview) + } + + if avatar, ok := metadata["avatar_url"].(string); ok && avatar != "" { + lines = append(lines, boxStyle+labelStyle("Avatar: ")+avatar) + } + + if location, ok := metadata["location"].(string); ok && location != "" { + lines = append(lines, boxStyle+labelStyle("Location: ")+location) + } + + if website, ok := metadata["website"].(string); ok && website != "" { + lines = append(lines, boxStyle+labelStyle("Website: ")+website) + } + + if joinDate, ok := metadata["join_date"].(string); ok && joinDate != "" { + lines = append(lines, boxStyle+labelStyle("Joined: ")+joinDate) + } + + if followers, ok := metadata["follower_count"].(float64); ok && followers > 0 { + lines = append(lines, boxStyle+labelStyle("Followers: ")+fmt.Sprintf("%d", int(followers))) + } + + if following, ok := metadata["following_count"].(float64); ok && following > 0 { + lines = append(lines, boxStyle+labelStyle("Following: ")+fmt.Sprintf("%d", int(following))) + } + + if verified, ok := metadata["is_verified"].(bool); ok && verified { + lines = append(lines, boxStyle+labelStyle("Verified: ")+Green("✓ Yes")) + } + + if additionalLinks, ok := metadata["additional_links"].(map[string]interface{}); ok && len(additionalLinks) > 0 { + linkCount := 0 + linkLines := []string{} + for key, value := range additionalLinks { + if strVal, ok := value.(string); ok { + linkLines = append(linkLines, Dim(" ├─ ")+labelStyle(key+": ")+strVal) + linkCount++ + } + } + if linkCount > 0 { + lastBoxStyle := Dim(" └─ ") + lines = append(lines, lastBoxStyle+labelStyle("Links:")) + for i, linkLine := range linkLines { + if i == linkCount-1 { + linkLine = strings.Replace(linkLine, "├─", "└─", 1) + } + lines = append(lines, linkLine) + } + } + } else { + if len(lines) > 0 { + lastIdx := len(lines) - 1 + lines[lastIdx] = strings.Replace(lines[lastIdx], "├─", "└─", 1) + } + } + + if len(lines) > 0 { + return strings.Join(lines, "\n") + "\n" + } + return "" +} diff --git a/internal/formatter/x.go b/internal/formatter/x.go new file mode 100644 index 0000000..f701797 --- /dev/null +++ b/internal/formatter/x.go @@ -0,0 +1,379 @@ +package formatter + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "text/tabwriter" + + "git.db.org.ai/dborg/internal/models" +) + +func FormatXHistory(resp *models.XResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var buf bytes.Buffer + + if resp.Error != "" { + fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", resp.Error))) + return buf.String(), nil + } + + fmt.Fprintf(&buf, "%s\n\n", Bold(Cyan(fmt.Sprintf("Username History for @%s", resp.Username)))) + + if len(resp.PreviousUsernames) == 0 { + fmt.Fprintf(&buf, "%s\n", Gray("No previous usernames found")) + } else { + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "%s\t%s\t%s\n", Bold("#"), Bold("Username"), Bold("Time Ago")) + + for i, u := range resp.PreviousUsernames { + fmt.Fprintf(w, "%s\t%s\t%s\n", + Yellow(fmt.Sprintf("%d", i+1)), + Cyan(fmt.Sprintf("@%s", u.Username)), + Gray(u.TimeAgo)) + } + w.Flush() + } + + fmt.Fprintf(&buf, "\n%s ", Blue("Credits Remaining:")) + if resp.Credits.Unlimited { + fmt.Fprintf(&buf, "%s\n", Green("Unlimited")) + } else { + fmt.Fprintf(&buf, "%s\n", FormatCredits(int64(resp.Credits.Remaining))) + } + + return buf.String(), nil +} + +func FormatXTweets(streamResp *models.TweetsStreamResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(streamResp, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var buf bytes.Buffer + + if streamResp.Error != "" { + fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", streamResp.Error))) + return buf.String(), nil + } + + if streamResp.Tweet != nil { + tweet := streamResp.Tweet + + if tweet.Name != "" { + fmt.Fprintf(&buf, "%s ", Cyan(tweet.Name)) + } + if tweet.Handle != "" { + fmt.Fprintf(&buf, "%s", Gray(fmt.Sprintf("(@%s)", tweet.Handle))) + } + if tweet.Name != "" || tweet.Handle != "" { + fmt.Fprintf(&buf, "\n") + } + + if tweet.Text != "" { + fmt.Fprintf(&buf, "%s\n", Green(tweet.Text)) + } + + if tweet.Type != "" { + fmt.Fprintf(&buf, "%s %s\n", Dim("Type:"), tweet.Type) + } + + if tweet.URL != "" { + fmt.Fprintf(&buf, "%s\n", Blue(tweet.URL)) + } + + fmt.Fprintf(&buf, "%s\n", Dim("───")) + } + + if streamResp.Progress != nil { + fmt.Fprintf(&buf, "%s %s/%s\n", + Dim("Progress:"), + Yellow(fmt.Sprintf("%d", streamResp.Progress.Current)), + Yellow(fmt.Sprintf("%d", streamResp.Progress.Total))) + } + + if streamResp.Complete != nil { + fmt.Fprintf(&buf, "\n%s\n", Bold(Green("Complete!"))) + fmt.Fprintf(&buf, "%s %s\n", Dim("Duration:"), streamResp.Complete.Duration) + fmt.Fprintf(&buf, "%s %s\n", + Dim("Total Fetched:"), + Green(fmt.Sprintf("%d", streamResp.Complete.TotalFetched))) + if streamResp.Complete.TotalFailed > 0 { + fmt.Fprintf(&buf, "%s %s\n", + Dim("Total Failed:"), + Yellow(fmt.Sprintf("%d", streamResp.Complete.TotalFailed))) + } + } + + return buf.String(), nil +} + +func FormatXFirstFollowers(resp *models.FirstFollowersResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var buf bytes.Buffer + + fmt.Fprintf(&buf, "%s\n\n", Bold(Cyan(fmt.Sprintf("First 20 Followers of @%s", resp.Username)))) + + if len(resp.Followers) == 0 { + fmt.Fprintf(&buf, "%s\n", Gray("No followers found")) + } else { + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "%s\t%s\t%s\n", Bold("#"), Bold("Username"), Bold("Name")) + + for _, f := range resp.Followers { + fmt.Fprintf(w, "%s\t%s\t%s\n", + Yellow(fmt.Sprintf("%d", f.Number)), + Cyan(fmt.Sprintf("@%s", f.Username)), + Green(f.Name)) + } + w.Flush() + } + + fmt.Fprintf(&buf, "\n%s ", Blue("Credits Remaining:")) + if resp.Credits.Unlimited { + fmt.Fprintf(&buf, "%s\n", Green("Unlimited")) + } else { + fmt.Fprintf(&buf, "%s\n", FormatCredits(int64(resp.Credits.Remaining))) + } + + return buf.String(), nil +} + +func FormatXNotableFollowers(resp *models.NotableFollowersResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var buf bytes.Buffer + + fmt.Fprintf(&buf, "%s\n\n", Bold(Cyan(fmt.Sprintf("Notable Followers of @%s", resp.Username)))) + + if len(resp.Followers) == 0 { + fmt.Fprintf(&buf, "%s\n", Gray("No notable followers found")) + } else { + w := tabwriter.NewWriter(&buf, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "%s\t%s\t%s\n", Bold("Username"), Bold("Followers"), Bold("Score")) + + for _, f := range resp.Followers { + fmt.Fprintf(w, "%s\t%s\t%s\n", + Cyan(fmt.Sprintf("@%s", f.Username)), + Green(f.FollowerCount), + Magenta(fmt.Sprintf("%.2f", f.Score))) + } + w.Flush() + } + + fmt.Fprintf(&buf, "\n%s ", Blue("Credits Remaining:")) + if resp.Credits.Unlimited { + fmt.Fprintf(&buf, "%s\n", Green("Unlimited")) + } else { + fmt.Fprintf(&buf, "%s\n", FormatCredits(int64(resp.Credits.Remaining))) + } + + return buf.String(), nil +} + +func FormatXReplies(streamResp *models.TweetsStreamResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(streamResp, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var buf bytes.Buffer + + if streamResp.Error != "" { + fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", streamResp.Error))) + return buf.String(), nil + } + + if streamResp.Tweet != nil { + tweet := streamResp.Tweet + + if tweet.Name != "" { + fmt.Fprintf(&buf, "%s ", Cyan(tweet.Name)) + } + if tweet.Handle != "" { + fmt.Fprintf(&buf, "%s", Gray(fmt.Sprintf("(@%s)", tweet.Handle))) + } + if tweet.Name != "" || tweet.Handle != "" { + fmt.Fprintf(&buf, "\n") + } + + if tweet.Text != "" { + lines := strings.Split(tweet.Text, "\n") + for _, line := range lines { + if len(line) > 80 { + wrapped := wrapText(line, 80) + for _, wLine := range wrapped { + fmt.Fprintf(&buf, "%s\n", Green(wLine)) + } + } else { + fmt.Fprintf(&buf, "%s\n", Green(line)) + } + } + } + + if tweet.URL != "" { + fmt.Fprintf(&buf, "%s\n", Blue(tweet.URL)) + } + + fmt.Fprintf(&buf, "%s\n", Dim("───")) + } + + if streamResp.Progress != nil { + fmt.Fprintf(&buf, "%s %s/%s\n", + Dim("Progress:"), + Yellow(fmt.Sprintf("%d", streamResp.Progress.Current)), + Yellow(fmt.Sprintf("%d", streamResp.Progress.Total))) + } + + if streamResp.Complete != nil { + fmt.Fprintf(&buf, "\n%s\n", Bold(Green("Complete!"))) + fmt.Fprintf(&buf, "%s %s\n", Dim("Duration:"), streamResp.Complete.Duration) + fmt.Fprintf(&buf, "%s %s\n", + Dim("Total Replies:"), + Green(fmt.Sprintf("%d", streamResp.Complete.TotalFetched))) + if streamResp.Complete.TotalFailed > 0 { + fmt.Fprintf(&buf, "%s %s\n", + Dim("Total Failed:"), + Yellow(fmt.Sprintf("%d", streamResp.Complete.TotalFailed))) + } + } + + return buf.String(), nil +} + +func FormatXSearch(streamResp *models.TweetsStreamResponse, asJSON bool) (string, error) { + if asJSON { + data, err := json.MarshalIndent(streamResp, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal JSON: %w", err) + } + return string(data), nil + } + + var buf bytes.Buffer + + if streamResp.Error != "" { + fmt.Fprintf(&buf, "%s\n", Red(fmt.Sprintf("Error: %s", streamResp.Error))) + return buf.String(), nil + } + + if streamResp.Tweet != nil { + tweet := streamResp.Tweet + + if tweet.Name != "" { + fmt.Fprintf(&buf, "%s ", Cyan(tweet.Name)) + } + if tweet.Handle != "" { + fmt.Fprintf(&buf, "%s", Gray(fmt.Sprintf("(@%s)", tweet.Handle))) + } + if tweet.Name != "" || tweet.Handle != "" { + fmt.Fprintf(&buf, "\n") + } + + if tweet.Text != "" { + lines := strings.Split(tweet.Text, "\n") + for _, line := range lines { + if len(line) > 80 { + wrapped := wrapText(line, 80) + for _, wLine := range wrapped { + fmt.Fprintf(&buf, "%s\n", Green(wLine)) + } + } else { + fmt.Fprintf(&buf, "%s\n", Green(line)) + } + } + } + + if tweet.Type != "" { + fmt.Fprintf(&buf, "%s %s\n", Dim("Type:"), tweet.Type) + } + + if tweet.URL != "" { + fmt.Fprintf(&buf, "%s\n", Blue(tweet.URL)) + } + + fmt.Fprintf(&buf, "%s\n", Dim("───")) + } + + if streamResp.Progress != nil { + fmt.Fprintf(&buf, "%s %s/%s\n", + Dim("Progress:"), + Yellow(fmt.Sprintf("%d", streamResp.Progress.Current)), + Yellow(fmt.Sprintf("%d", streamResp.Progress.Total))) + } + + if streamResp.Complete != nil { + fmt.Fprintf(&buf, "\n%s\n", Bold(Green("Search Complete!"))) + fmt.Fprintf(&buf, "%s %s\n", Dim("Duration:"), streamResp.Complete.Duration) + fmt.Fprintf(&buf, "%s %s\n", + Dim("Total Results:"), + Green(fmt.Sprintf("%d", streamResp.Complete.TotalFetched))) + if streamResp.Complete.TotalFailed > 0 { + fmt.Fprintf(&buf, "%s %s\n", + Dim("Total Failed:"), + Yellow(fmt.Sprintf("%d", streamResp.Complete.TotalFailed))) + } + } + + return buf.String(), nil +} + +func wrapText(text string, width int) []string { + if len(text) <= width { + return []string{text} + } + + var lines []string + words := strings.Fields(text) + if len(words) == 0 { + return []string{text} + } + + var currentLine strings.Builder + for _, word := range words { + if currentLine.Len() == 0 { + currentLine.WriteString(word) + } else if currentLine.Len()+1+len(word) <= width { + currentLine.WriteString(" ") + currentLine.WriteString(word) + } else { + lines = append(lines, currentLine.String()) + currentLine.Reset() + currentLine.WriteString(word) + } + } + + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) + } + + return lines +} diff --git a/internal/utils/output.go b/internal/utils/output.go index e8e97bc..6d7ad79 100644 --- a/internal/utils/output.go +++ b/internal/utils/output.go @@ -11,14 +11,14 @@ import ( const ( colorReset = "\033[0m" colorKey = "\033[36m" - colorString = "\033[32m" + colorString = "\033[93m" colorNumber = "\033[33m" colorBool = "\033[35m" colorNull = "\033[90m" colorBrace = "\033[37m" ) -func colorizeJSON(data []byte) string { +func ColorizeJSON(data []byte) string { if !isTerminal() { return string(data) } @@ -123,7 +123,7 @@ func PrintJSON(data any) error { if err != nil { return fmt.Errorf("failed to format JSON: %w", err) } - fmt.Println(colorizeJSON(output)) + fmt.Println(ColorizeJSON(output)) return nil } |
