From 3a8877bf39731dad7f2f439a8a277e2e2061b0ae Mon Sep 17 00:00:00 2001 From: bytebl33d <61542339+bytebl33d@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:17:45 +0200 Subject: [PATCH 1/3] Fixed bugs with new API v5 - Fixed handling of user profile activity - Fixed scheduled machine releases --- cmd/info.go | 34 ++++++++--- cmd/machines.go | 76 +++++++++++++++++++----- lib/machines/cache.go | 22 +++++-- lib/utils/tui.go | 132 ++++++++++++++++++++++++++++++++---------- 4 files changed, 207 insertions(+), 57 deletions(-) diff --git a/cmd/info.go b/cmd/info.go index 7b6b394..a0dd94a 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -22,8 +22,12 @@ type Response struct { } // Retrieves data for user profile -func fetchData(itemID int, endpoint string, infoKey string) (map[string]interface{}, error) { - url := fmt.Sprintf("%s%s%d", config.BaseHackTheBoxAPIURL, endpoint, itemID) +func fetchData(itemID int, endpoint string, infoKey string, apiVersion string) (map[string]interface{}, error) { + baseURL := config.BaseHackTheBoxAPIURL + if apiVersion == "v5" { + baseURL = strings.Replace(config.BaseHackTheBoxAPIURL, "/v4", "/v5", 1) + } + url := fmt.Sprintf("%s%s%d", baseURL, endpoint, itemID) config.GlobalConfig.Logger.Debug(fmt.Sprintf("URL: %s", url)) resp, err := utils.HtbRequest(http.MethodGet, url, nil) @@ -32,6 +36,17 @@ func fetchData(itemID int, endpoint string, infoKey string) (map[string]interfac } parsedInfo := utils.ParseJsonMessage(resp, infoKey) + + // Handle v5 API response + if apiVersion == "v5" && infoKey == "data" { + if dataArray, ok := parsedInfo.([]interface{}); ok { + return map[string]interface{}{ + "activity": dataArray, + }, nil + } + return nil, errors.New("Could not convert data to array") + } + dataMap, ok := parsedInfo.(map[string]interface{}) if !ok { return nil, errors.New("Could not convert parsedInfo to map[string]interface{}") @@ -39,6 +54,7 @@ func fetchData(itemID int, endpoint string, infoKey string) (map[string]interfac return dataMap, nil } + // fetchAndDisplayInfo fetches and displays information based on the specified parameters. func fetchAndDisplayInfo(url, header string, params []string, elementType string) error { w := utils.SetTabWriterHeader(header) @@ -82,18 +98,20 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string data := info.(map[string]interface{}) endpoints := []struct { - name string - url string + name string + url string + apiVersion string + infoKey string }{ - {"Fortresses", "/user/profile/progress/fortress/"}, - {"Prolabs", "/user/profile/progress/prolab/"}, - {"Activity", "/user/profile/activity/"}, + {"Fortresses", "/user/profile/progress/fortress/", "v4", "profile"}, + {"Prolabs", "/user/profile/progress/prolab/", "v4", "profile"}, + {"Activity", "/user/profile/activity/", "v5", "data"}, } dataMaps := make(map[string]map[string]interface{}) for _, ep := range endpoints { - data, err := fetchData(itemID, ep.url, "profile") + data, err := fetchData(itemID, ep.url, ep.infoKey, ep.apiVersion) if err != nil { fmt.Printf("Error fetching data for %s: %v\n", ep.name, err) continue diff --git a/cmd/machines.go b/cmd/machines.go index 0eaa322..17b1f3f 100644 --- a/cmd/machines.go +++ b/cmd/machines.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "os" + "strings" "time" "github.com/GoToolSharing/htb-cli/config" @@ -20,7 +21,6 @@ import ( const ( machineURL = config.BaseHackTheBoxAPIURL + "/machine/paginated/?per_page=20" retiredURL = config.BaseHackTheBoxAPIURL + "/machine/list/retired/paginated/?per_page=20&sort_by=release-date" - scheduledURL = config.BaseHackTheBoxAPIURL + "/machine/unreleased/" activeTitle = "Active" retiredTitle = "Retired" scheduledTitle = "Scheduled" @@ -30,6 +30,8 @@ const ( Computer = "\U0001F5A5 " ) +var scheduledURL = strings.Replace(config.BaseHackTheBoxAPIURL, "/v4", "/v5", 1) + "/machines?per_page=15&state=unreleased" + // getColorFromDifficultyText returns the color corresponding to the given difficulty. func getColorFromDifficultyText(difficultyText string) string { switch difficultyText { @@ -67,48 +69,94 @@ func createFlex(info interface{}, title string, isScheduled bool) (*tview.Flex, data := value.(map[string]interface{}) // Determining the color according to difficulty - key := "Undefined" _ = key if title == "Scheduled" { - key = data["difficulty_text"].(string) + if val, ok := data["difficultyText"].(string); ok { + key = val + } } else { - key = data["difficultyText"].(string) + if val, ok := data["difficultyText"].(string); ok { + key = val + } } color := getColorFromDifficultyText(key) - osEmoji := getOSEmoji(data["os"].(string)) + + osStr := "Unknown" + if os, ok := data["os"].(string); ok { + osStr = os + } + osEmoji := getOSEmoji(osStr) var formatString string // Choice of display format depending on the nature of the information if isScheduled { + diffText := "Undefined" + if val, ok := data["difficultyText"].(string); ok { + diffText = val + } + name := "Undefined" + if val, ok := data["name"].(string); ok { + name = val + } formatString = fmt.Sprintf("%-10s %s%-10s %s%-10s[-]", - data["name"], osEmoji, data["os"], color, data["difficulty_text"]) + name, osEmoji, osStr, color, diffText) } else { // Convert and format date - parsedDate, err := time.Parse(time.RFC3339Nano, data["release"].(string)) + var releaseDateStr string + if val, ok := data["release"].(string); ok { + releaseDateStr = val + } else if val, ok := data["releaseDate"].(string); ok { + releaseDateStr = val + } + + if releaseDateStr == "" { + continue + } + + parsedDate, err := time.Parse(time.RFC3339Nano, releaseDateStr) if err != nil { - return nil, fmt.Errorf("error parsing date: %v", err) + // Try RFC3339 format for v5 API + parsedDate, err = time.Parse(time.RFC3339, releaseDateStr) + if err != nil { + return nil, fmt.Errorf("error parsing date: %v", err) + } } formattedDate := parsedDate.Format("02 January 2006") userEmoji := CrossMark + "User" - if value, ok := data["authUserInUserOwns"]; ok && value != nil { - if value.(bool) { + if val, ok := data["authUserInUserOwns"]; ok && val != nil { + if boolVal, ok := val.(bool); ok && boolVal { userEmoji = CheckMark + "User" } } rootEmoji := CrossMark + "Root" - if value, ok := data["authUserInRootOwns"]; ok && value != nil { - if value.(bool) { + if val, ok := data["authUserInRootOwns"]; ok && val != nil { + if boolVal, ok := val.(bool); ok && boolVal { rootEmoji = CheckMark + "Root" } } + name := "Undefined" + if val, ok := data["name"].(string); ok { + name = val + } + + diffText := "Undefined" + if val, ok := data["difficultyText"].(string); ok { + diffText = val + } + + star := 0.0 + if val, ok := data["star"].(float64); ok { + star = val + } + formatString = fmt.Sprintf("%-15s %s%-10s %s%-10s[-] %-5v %-5v %-7v %-30s", - data["name"], osEmoji, data["os"], color, data["difficultyText"], - data["star"], userEmoji, rootEmoji, formattedDate) + name, osEmoji, osStr, color, diffText, + star, userEmoji, rootEmoji, formattedDate) } flex.AddItem(tview.NewTextView().SetText(formatString).SetDynamicColors(true), 1, 0, false) diff --git a/lib/machines/cache.go b/lib/machines/cache.go index 25e31b9..7985845 100644 --- a/lib/machines/cache.go +++ b/lib/machines/cache.go @@ -91,27 +91,39 @@ func InsertMachines(db *sql.DB, data interface{}, title string) error { id := int(machineMap["id"].(float64)) name := machineMap["name"].(string) os := machineMap["os"].(string) - releaseDateStr := machineMap["release"].(string) status := title var difficulty string var star float64 var userOwns bool var rootOwns bool + var releaseDateStr string - // Scheduled machines - if val, ok := machineMap["difficulty_text"].(string); ok { - difficulty = val + // Scheduled machines (v5 API) + if val, ok := machineMap["releaseDate"].(string); ok { + releaseDateStr = val + difficulty = machineMap["difficultyText"].(string) star = 0 userOwns = false rootOwns = false } else { + // Active/Retired machines (v4 API) + releaseDateStr = machineMap["release"].(string) difficulty = machineMap["difficultyText"].(string) star = machineMap["star"].(float64) userOwns = machineMap["authUserInUserOwns"].(bool) rootOwns = machineMap["authUserInRootOwns"].(bool) } - releaseDate, err := time.Parse("2006-01-02T15:04:05.000000Z", releaseDateStr) + // Parse date - handle both v4 and v5 formats + var releaseDate time.Time + var err error + if status == "Scheduled" { + // v5 format: 2026-08-29T19:00:00.000Z + releaseDate, err = time.Parse(time.RFC3339, releaseDateStr) + } else { + // v4 format: 2006-01-02T15:04:05.000000Z + releaseDate, err = time.Parse("2006-01-02T15:04:05.000000Z", releaseDateStr) + } if err != nil { return fmt.Errorf("date parsing error for %s: %v", name, err) } diff --git a/lib/utils/tui.go b/lib/utils/tui.go index f7c89f5..395a976 100644 --- a/lib/utils/tui.go +++ b/lib/utils/tui.go @@ -3,6 +3,7 @@ package utils import ( "fmt" "strings" + "time" "github.com/rivo/tview" ) @@ -20,8 +21,15 @@ func calculateSpacing(baseName string, maxNameLength int) string { // Parse and return the user's subscription level func parseUserSubscription(profile map[string]interface{}) string { - isVip := profile["isVip"].(bool) - isDedicatedVIP := profile["isDedicatedVip"].(bool) + isVip := false + if val, ok := profile["isVip"].(bool); ok { + isVip = val + } + + isDedicatedVIP := false + if val, ok := profile["isDedicatedVip"].(bool); ok { + isDedicatedVIP = val + } if isDedicatedVIP { return "VIP+" @@ -74,36 +82,78 @@ func displayInfoPanel(title string, items []interface{}, formatterFunc func(map[ // Get the right keys for display func displayInfo(dataMaps map[string]map[string]interface{}, dataMapKey string, title string, flagSymbol string, maxNameLength int, paddingBottom int) *tview.Flex { - items, ok := dataMaps[strings.ToUpper(string(dataMapKey[0]))+dataMapKey[1:]][dataMapKey].([]interface{}) + // Check if the capitalized key exists first + capitalizedKey := strings.ToUpper(string(dataMapKey[0])) + dataMapKey[1:] + dataMap, ok := dataMaps[capitalizedKey] + if !ok { + // Key doesn't exist in dataMaps + return nil + } + + // Check if the inner key exists + itemsInterface, ok := dataMap[dataMapKey] + if !ok { + // Inner key doesn't exist + return nil + } + + // Try to convert to slice + items, ok := itemsInterface.([]interface{}) if !ok { - fmt.Println("Error: couldn't convert data") + fmt.Printf("Error: couldn't convert data for key '%s'\n", dataMapKey) return nil } var formatterFunc func(item map[string]interface{}) string if dataMapKey == "activity" { formatterFunc = func(item map[string]interface{}) string { - var object_type interface{} - switch item["object_type"].(string) { - case "fortress": - object_type = item["flag_title"] - case "challenge": - object_type = item["challenge_category"] - case "machine": - switch item["type"].(string) { - case "root": - object_type = "System" - case "user": - object_type = "User" - default: - object_type = item["type"].(string) + // Handle v5 API format + activityType := "unknown" + if typeVal, ok := item["type"].(string); ok { + activityType = typeVal + } + + name := "N/A" + if nameVal, ok := item["name"].(string); ok { + name = nameVal + } + + points := 0.0 + if pointsVal, ok := item["points"].(float64); ok { + points = pointsVal + } + + bloodStatus := "" + if blood, ok := item["blood"].(bool); ok && blood { + bloodStatus = " [red]🩸[-]" + } + + // Parse and format ownDate + dateStr := "" + if ownDate, ok := item["ownDate"].(string); ok { + if parsedDate, err := time.Parse(time.RFC3339, ownDate); err == nil { + dateStr = parsedDate.Format("2006-01-02 15:04") + } else { + dateStr = ownDate } } - return fmt.Sprintf("[::b]Owned %v - %s %s - %s - [green]+[%vpts][-]", object_type, item["name"], item["object_type"], item["date_diff"], item["points"]) + + return fmt.Sprintf("[::b]%s - %s - [green]+[%.0fpts][-] (%s)%s", activityType, name, points, dateStr, bloodStatus) } } else { formatterFunc = func(item map[string]interface{}) string { - return formatFlagInfo(item["name"].(string), item["owned_flags"].(float64), item["total_flags"].(float64), flagSymbol, maxNameLength) + name := "N/A" + if nameVal, ok := item["name"].(string); ok { + name = nameVal + } + var owned, total float64 = 0, 0 + if ownedVal, ok := item["owned_flags"].(float64); ok { + owned = ownedVal + } + if totalVal, ok := item["total_flags"].(float64); ok { + total = totalVal + } + return formatFlagInfo(name, owned, total, flagSymbol, maxNameLength) } } @@ -117,13 +167,21 @@ func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[ universityName, universityRank := "N/A", "N/A" if teamMap, ok := profile["team"].(map[string]interface{}); ok && teamMap != nil { - teamName = teamMap["name"].(string) - teamRank = fmt.Sprintf("%v", teamMap["ranking"].(float64)) + if name, ok := teamMap["name"].(string); ok { + teamName = name + } + if rank, ok := teamMap["ranking"].(float64); ok { + teamRank = fmt.Sprintf("%v", rank) + } } if universityMap, ok := profile["university"].(map[string]interface{}); ok && universityMap != nil { - universityName = universityMap["name"].(string) - universityRank = fmt.Sprintf("%v", universityMap["rank"].(float64)) + if name, ok := universityMap["name"].(string); ok { + universityName = name + } + if rank, ok := universityMap["rank"].(float64); ok { + universityRank = fmt.Sprintf("%v", rank) + } } subscription := parseUserSubscription(profile) @@ -141,7 +199,11 @@ func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[ userInformationsFlex := tview.NewFlex().SetDirection(tview.FlexRow) userInformationsFlex.SetBorder(true).SetTitle("Profile").SetTitleAlign(tview.AlignLeft) - userInformationsFlex.AddItem(tview.NewTextView().SetText(fmt.Sprintf("[::b]ID : %d[-]", int(profile["id"].(float64)))).SetDynamicColors(true), 1, 0, false) + id := "N/A" + if idVal, ok := profile["id"].(float64); ok { + id = fmt.Sprintf("%d", int(idVal)) + } + userInformationsFlex.AddItem(tview.NewTextView().SetText(fmt.Sprintf("[::b]ID : %v[-]", id)).SetDynamicColors(true), 1, 0, false) userInformationsFlex.AddItem(tview.NewTextView().SetText(fmt.Sprintf("[::b]Name : %v[-]", profile["name"])).SetDynamicColors(true), 1, 0, false) userInformationsFlex.AddItem(tview.NewTextView().SetText(fmt.Sprintf("[::b]Team : %v[-]", teamName)).SetDynamicColors(true), 1, 0, false) userInformationsFlex.AddItem(tview.NewTextView().SetText(fmt.Sprintf("[::b]University : %v[-]", universityName)).SetDynamicColors(true), 1, 0, false) @@ -179,15 +241,25 @@ func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[ advancedLabsFlex := tview.NewFlex(). SetDirection(tview.FlexRow). - SetDirection(tview.FlexColumn). - AddItem(fortressesPanel, 0, 1, false). - AddItem(prolabsPanel, 0, 1, false) + SetDirection(tview.FlexColumn) + + // Only add panels that are not nil + if fortressesPanel != nil { + advancedLabsFlex.AddItem(fortressesPanel, 0, 1, false) + } + if prolabsPanel != nil { + advancedLabsFlex.AddItem(prolabsPanel, 0, 1, false) + } leftFlex := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(userInformationsContainer, 0, 1, false). - AddItem(advancedLabsFlex, 0, 1, false). - AddItem(activityPanel, 0, 2, false) + AddItem(advancedLabsFlex, 0, 1, false) + + // Only add activity panel if it's not nil + if activityPanel != nil { + leftFlex.AddItem(activityPanel, 0, 2, false) + } mainFlex := tview.NewFlex(). AddItem(leftFlex, 0, 2, false) From 14c9fbe67da78de057bd69c0954396e04684c0b3 Mon Sep 17 00:00:00 2001 From: bytebl33d <61542339+bytebl33d@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:38:21 +0200 Subject: [PATCH 2/3] Fix: sherlock search and download --- cmd/sherlocks.go | 12 +++++-- go.mod | 2 +- lib/sherlocks/sherlocks.go | 70 +++++++++++++++++++++++++++++--------- 3 files changed, 64 insertions(+), 20 deletions(-) diff --git a/cmd/sherlocks.go b/cmd/sherlocks.go index fee216a..1379d20 100644 --- a/cmd/sherlocks.go +++ b/cmd/sherlocks.go @@ -71,11 +71,17 @@ var sherlocksCmd = &cobra.Command{ return } for _, task := range data.Tasks { + status := "" if task.Completed { - fmt.Printf("\n%s (DONE) :\n%s\n\n", task.Title, task.Description) - } else { - fmt.Printf("\n%s :\n%s\n\n", task.Title, task.Description) + status = " (DONE)" } + + fmt.Printf("\n%s%s :\n%s\n", task.Title, status, task.Description) + + if task.MaskedFlag != "" { + fmt.Printf("Format : %s\n", task.MaskedFlag) + } + fmt.Println() } return } diff --git a/go.mod b/go.mod index 81db6e8..35cdd3c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/briandowns/spinner v1.23.2 github.com/chzyer/readline v1.5.1 github.com/gorilla/websocket v1.5.3 + github.com/mattn/go-sqlite3 v1.14.24 github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.8.1 @@ -26,7 +27,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect diff --git a/lib/sherlocks/sherlocks.go b/lib/sherlocks/sherlocks.go index ebc5d4c..5f721d9 100644 --- a/lib/sherlocks/sherlocks.go +++ b/lib/sherlocks/sherlocks.go @@ -4,9 +4,11 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "net/http" "os" + "regexp" "strconv" "strings" @@ -16,14 +18,11 @@ import ( "github.com/sahilm/fuzzy" ) -// getSherlockDownloadLink constructs and returns the download link for a specific Sherlock challenge. +var metaRefreshURLPattern = regexp.MustCompile(`url=['"]?([^'">]+)['"]?`) + func getDownloadLink(sherlockID string) (string, error) { url := fmt.Sprintf("%s/sherlocks/%s/download_link", config.BaseHackTheBoxAPIURL, sherlockID) - // url := "https://www.hackthebox.com/api/v4/challenge/download/196" - - // return url, nil - resp, err := utils.HtbRequest(http.MethodGet, url, nil) if err != nil { return "", err @@ -49,32 +48,71 @@ func getDownloadLink(sherlockID string) (string, error) { return data.URL, nil } +// getSherlockDownloadLink constructs and returns the download link for a specific Sherlock challenge. +func resolveDownloadURL(redirectEndpointURL string) (string, error) { + resp, err := utils.HtbRequest(http.MethodGet, redirectEndpointURL, nil) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + if location == "" { + return "", fmt.Errorf("error: redirect response (status %d) had no Location header", resp.StatusCode) + } + return location, nil + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("error: unexpected status code %d while resolving download link", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + matches := metaRefreshURLPattern.FindSubmatch(body) + if len(matches) < 2 { + return "", fmt.Errorf("error: could not find redirect target in HTML response") + } + return html.UnescapeString(string(matches[1])), nil +} + // downloadFile downloads the Sherlock file from a given URL to a specified download path. -func downloadFile(url string, downloadPath string) error { - resp, err := utils.HtbRequest(http.MethodGet, url, nil) +func downloadFile(downloadLinkURL string, downloadPath string) error { + finalURL, err := resolveDownloadURL(downloadLinkURL) + if err != nil { + return err + } + + config.GlobalConfig.Logger.Debug(fmt.Sprintf("Resolved final download URL: %s", finalURL)) + + resp, err := http.Get(finalURL) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - fmt.Println("error: Status code:", resp.StatusCode) - return nil + return fmt.Errorf("error: unexpected status code %d while downloading file", resp.StatusCode) } outFile, err := os.Create(downloadPath) if err != nil { return err } - defer outFile.Close() _, err = io.Copy(outFile, resp.Body) + outFile.Close() if err != nil { return err } - fmt.Println("Archive downloaded successfully. The password for unlock is: hacktheblue") - fmt.Println("") + fmt.Println("Archive downloaded successfully to:", downloadPath) + fmt.Println("The password for unlock is: hacktheblue") + return nil } @@ -185,18 +223,18 @@ func GetGeneralInformations(sherlockID string, sherlockDownloadPath string) erro info := utils.ParseJsonMessage(resp, "data").(map[string]interface{}) if sherlockDownloadPath != "" { - url, err := getDownloadLink(sherlockID) + downloadURL, err := getDownloadLink(sherlockID) if err != nil { return err } - err = downloadFile(url, sherlockDownloadPath) + err = downloadFile(downloadURL, sherlockDownloadPath) if err != nil { return err } } config.GlobalConfig.Logger.Debug(fmt.Sprintf("Informations: %v", info)) - fmt.Println("Scenario :", info["scenario"]) + fmt.Println("\nScenario :", info["scenario"]) fmt.Println("\nFile :", info["file_name"]) fmt.Println("File Size :", info["file_size"]) return nil @@ -204,7 +242,7 @@ func GetGeneralInformations(sherlockID string, sherlockDownloadPath string) erro // SearchIDByName searches for a Sherlock challenge by name and returns its ID. func SearchIDByName(sherlockSearch string) (string, error) { - url := fmt.Sprintf("%s/sherlocks", config.BaseHackTheBoxAPIURL) + url := fmt.Sprintf("%s/sherlocks?keyword=%s", config.BaseHackTheBoxAPIURL, strings.ToLower(sherlockSearch)) resp, err := utils.HtbRequest(http.MethodGet, url, nil) if err != nil { return "", err From 87fa7f2ac3eec5206792c9b32d5d0c71e1a70630 Mon Sep 17 00:00:00 2001 From: bytebl33d <61542339+bytebl33d@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:46:55 +0200 Subject: [PATCH 3/3] feat(sherlocks): add interactive TUI, task hints, and release metadata --- cmd/sherlocks.go | 210 ++++++++++++++++++++++++++----------- lib/sherlocks/sherlocks.go | 4 +- lib/sherlocks/tui.go | 173 ++++++++++++++++++++++-------- 3 files changed, 283 insertions(+), 104 deletions(-) diff --git a/cmd/sherlocks.go b/cmd/sherlocks.go index 1379d20..35639aa 100644 --- a/cmd/sherlocks.go +++ b/cmd/sherlocks.go @@ -1,13 +1,18 @@ package cmd import ( + "encoding/json" "fmt" + "io" "net/http" "os" + "strconv" + "strings" "github.com/GoToolSharing/htb-cli/config" "github.com/GoToolSharing/htb-cli/lib/sherlocks" "github.com/GoToolSharing/htb-cli/lib/utils" + "github.com/chzyer/readline" "github.com/rivo/tview" "github.com/spf13/cobra" "go.uber.org/zap" @@ -41,98 +46,184 @@ var sherlocksCmd = &cobra.Command{ return } - if sherlockNameParam != "" { - sherlockID, err := sherlocks.SearchIDByName(sherlockNameParam) - if err != nil { - fmt.Println(err) - return - } - config.GlobalConfig.Logger.Debug(fmt.Sprintf("SherlockID: %s", sherlockID)) + sherlockFlag, err := cmd.Flags().GetString("flag") + if err != nil { + fmt.Println(err) + return + } - if sherlockTaskID != 0 { - err := sherlocks.GetTaskByID(sherlockID, sherlockTaskID, sherlockHint) + // If no name is provided, show the interactive UI + if sherlockNameParam == "" { + app := tview.NewApplication() + + getAndDisplayFlex := func(url, title string, isScheduled bool, flex *tview.Flex) error { + resp, err := utils.HtbRequest(http.MethodGet, url, nil) if err != nil { - fmt.Println(err) - return + return fmt.Errorf("failed to get data from %s: %w", url, err) } - return + defer resp.Body.Close() + + info := utils.ParseJsonMessage(resp, "data") + + sherlockFlex, err := sherlocks.CreateFlex(info, title, isScheduled) + if err != nil { + return fmt.Errorf("failed to create flex for %s: %w", title, err) + } + + flex.AddItem(sherlockFlex, 0, 1, false) + return nil } - err = sherlocks.GetGeneralInformations(sherlockID, sherlockDownloadPath) + leftFlex := tview.NewFlex().SetDirection(tview.FlexRow) + rightFlex := tview.NewFlex().SetDirection(tview.FlexRow) + if err := getAndDisplayFlex(sherlocks.SherlocksURL, sherlocks.ActiveSherlocksTitle, false, leftFlex); err != nil { + config.GlobalConfig.Logger.Error("", zap.Error(err)) + os.Exit(1) + } + + if err := getAndDisplayFlex(sherlocks.RetiredSherlocksURL, sherlocks.RetiredSherlocksTitle, false, leftFlex); err != nil { + config.GlobalConfig.Logger.Error("", zap.Error(err)) + os.Exit(1) + } + + if err := getAndDisplayFlex(sherlocks.ScheduledSherlocksURL, sherlocks.ScheduledSherlocksTitle, true, rightFlex); err != nil { + config.GlobalConfig.Logger.Error("", zap.Error(err)) + os.Exit(1) + } + + rightFlex.AddItem(tview.NewTextView().SetText("").SetDynamicColors(true), 0, 0, false) + + mainFlex := tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(leftFlex, 0, 3, false). + AddItem(rightFlex, 0, 1, false) + + if err := app.SetRoot(mainFlex, true).Run(); err != nil { + config.GlobalConfig.Logger.Error("", zap.Error(err)) + os.Exit(1) + } + + return + } + + sherlockID, err := sherlocks.SearchIDByName(sherlockNameParam) + if err != nil { + fmt.Printf("Error finding Sherlock: %v\n", err) + return + } + config.GlobalConfig.Logger.Debug(fmt.Sprintf("SherlockID: %s", sherlockID)) + + if sherlockTaskID != 0 && sherlockFlag == "" { + err := sherlocks.GetTaskByID(sherlockID, sherlockTaskID, sherlockHint) if err != nil { fmt.Println(err) return } + return + } - data, err := sherlocks.GetTasks(sherlockID) + if sherlockTaskID != 0 { + url := fmt.Sprintf("%s/sherlocks/%s/tasks", config.BaseHackTheBoxAPIURL, sherlockID) + resp, err := utils.HtbRequest(http.MethodGet, url, nil) if err != nil { - fmt.Println(err) + fmt.Printf("Error fetching tasks: %v\n", err) return } - for _, task := range data.Tasks { - status := "" - if task.Completed { - status = " (DONE)" - } + defer resp.Body.Close() - fmt.Printf("\n%s%s :\n%s\n", task.Title, status, task.Description) + jsonData, _ := io.ReadAll(resp.Body) + var sherlockData sherlocks.SherlockDataTasks + err = json.Unmarshal([]byte(jsonData), &sherlockData) + if err != nil { + fmt.Printf("Error parsing JSON: %v\n", err) + return + } - if task.MaskedFlag != "" { - fmt.Printf("Format : %s\n", task.MaskedFlag) - } - fmt.Println() + if sherlockTaskID < 1 || sherlockTaskID > len(sherlockData.Tasks) { + fmt.Printf("Invalid task ID: %d. Valid range: 1-%d\n", sherlockTaskID, len(sherlockData.Tasks)) + return } - return - } - app := tview.NewApplication() - getAndDisplayFlex := func(url, title string, isScheduled bool, flex *tview.Flex) error { - resp, err := utils.HtbRequest(http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to get data from %s: %w", url, err) + actualTaskID := sherlockData.Tasks[sherlockTaskID-1].ID + taskIDStr := strconv.Itoa(actualTaskID) + + if sherlockFlag != "" { + config.GlobalConfig.Logger.Debug(fmt.Sprintf("Submitting flag for task %d: %s", actualTaskID, sherlockFlag)) + + message, err := sherlocks.SubmitTask(sherlockID, taskIDStr, sherlockFlag) + if err != nil { + fmt.Printf("Error submitting flag: %v\n", err) + return + } + + fmt.Println(message) + return } - info := utils.ParseJsonMessage(resp, "data") + if sherlockHint && sherlockData.Tasks[sherlockTaskID-1].Hint != "" { + fmt.Printf("\n%s :\n%s\n\nHint : %s\nMasked Flag : %s\n", + sherlockData.Tasks[sherlockTaskID-1].Title, + sherlockData.Tasks[sherlockTaskID-1].Description, + sherlockData.Tasks[sherlockTaskID-1].Hint, + sherlockData.Tasks[sherlockTaskID-1].MaskedFlag) + } else { + fmt.Printf("\n%s :\n%s\n\nMasked Flag : %s\n", + sherlockData.Tasks[sherlockTaskID-1].Title, + sherlockData.Tasks[sherlockTaskID-1].Description, + sherlockData.Tasks[sherlockTaskID-1].MaskedFlag) + } - machineFlex, err := sherlocks.CreateFlex(info, title, isScheduled) + rl, err := readline.New("Answer: ") if err != nil { - return fmt.Errorf("failed to create flex for %s: %w", title, err) + panic(err) } + defer rl.Close() - flex.AddItem(machineFlex, 0, 1, false) - return nil - } + flag, err := rl.Readline() + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + return + } + flag = strings.TrimSpace(flag) + config.GlobalConfig.Logger.Debug(fmt.Sprintf("Flag: %s", flag)) - leftFlex := tview.NewFlex().SetDirection(tview.FlexRow) - rightFlex := tview.NewFlex().SetDirection(tview.FlexRow) + message, err := sherlocks.SubmitTask(sherlockID, taskIDStr, flag) + if err != nil { + fmt.Printf("Error submitting flag: %v\n", err) + return + } - if err := getAndDisplayFlex(sherlocks.SherlocksURL, sherlocks.ActiveSherlocksTitle, false, leftFlex); err != nil { - config.GlobalConfig.Logger.Error("", zap.Error(err)) - os.Exit(1) + fmt.Println(message) + return } - if err := getAndDisplayFlex(sherlocks.RetiredSherlocksURL, sherlocks.RetiredSherlocksTitle, false, leftFlex); err != nil { - config.GlobalConfig.Logger.Error("", zap.Error(err)) - os.Exit(1) + if sherlockDownloadPath != "" { + err = sherlocks.GetGeneralInformations(sherlockID, sherlockDownloadPath) + if err != nil { + fmt.Println(err) + return + } + return } - if err := getAndDisplayFlex(sherlocks.ScheduledSherlocksURL, sherlocks.ScheduledSherlocksTitle, true, rightFlex); err != nil { - config.GlobalConfig.Logger.Error("", zap.Error(err)) - os.Exit(1) + data, err := sherlocks.GetTasks(sherlockID) + if err != nil { + fmt.Println(err) + return } + for _, task := range data.Tasks { + status := "" + if task.Completed { + status = " (DONE)" + } - rightFlex.AddItem(tview.NewTextView().SetText("").SetDynamicColors(true), 0, 0, false) - - mainFlex := tview.NewFlex().SetDirection(tview.FlexColumn). - AddItem(leftFlex, 0, 3, false). - AddItem(rightFlex, 0, 1, false) + fmt.Printf("\n%s%s :\n%s\n", task.Title, status, task.Description) - if err := app.SetRoot(mainFlex, true).Run(); err != nil { - config.GlobalConfig.Logger.Error("", zap.Error(err)) - os.Exit(1) + if task.MaskedFlag != "" { + fmt.Printf("Format : %s\n", task.MaskedFlag) + } + fmt.Println() } - }, } @@ -141,5 +232,6 @@ func init() { sherlocksCmd.Flags().StringP("sherlock_name", "s", "", "Sherlock Name") sherlocksCmd.Flags().StringP("download", "d", "", "Download Sherlock Resources") sherlocksCmd.Flags().IntP("task", "t", 0, "Task ID") - sherlocksCmd.Flags().BoolP("hint", "", false, "Hint") + sherlocksCmd.Flags().StringP("flag", "f", "", "Task Flag (optional - if provided, submits without prompting)") + sherlocksCmd.Flags().BoolP("hint", "", false, "Show hint for the task") } diff --git a/lib/sherlocks/sherlocks.go b/lib/sherlocks/sherlocks.go index 5f721d9..762d821 100644 --- a/lib/sherlocks/sherlocks.go +++ b/lib/sherlocks/sherlocks.go @@ -117,7 +117,7 @@ func downloadFile(downloadLinkURL string, downloadPath string) error { } // submitTask sends a flag for a specific task of a Sherlock challenge and returns the server's response. -func submitTask(sherlockID string, taskID string, flag string) (string, error) { +func SubmitTask(sherlockID string, taskID string, flag string) (string, error) { url := fmt.Sprintf("%s/sherlocks/%s/tasks/%s/flag", config.BaseHackTheBoxAPIURL, sherlockID, taskID) body := map[string]string{ @@ -178,7 +178,7 @@ func GetTaskByID(sherlockID string, sherlockTaskID int, sherlockHint bool) error config.GlobalConfig.Logger.Debug(fmt.Sprintf("Flag: %s", flag)) taskID := strconv.Itoa(sherlockData.Tasks[sherlockTaskID-1].ID) - message, err := submitTask(sherlockID, taskID, flag) + message, err := SubmitTask(sherlockID, taskID, flag) if err != nil { return err diff --git a/lib/sherlocks/tui.go b/lib/sherlocks/tui.go index b1a4aac..b46f2ca 100644 --- a/lib/sherlocks/tui.go +++ b/lib/sherlocks/tui.go @@ -2,15 +2,18 @@ package sherlocks import ( "fmt" + "strconv" + "time" "github.com/GoToolSharing/htb-cli/config" + "github.com/GoToolSharing/htb-cli/lib/utils" "github.com/rivo/tview" ) const ( - SherlocksURL = config.BaseHackTheBoxAPIURL + "/sherlocks?state=active" - RetiredSherlocksURL = config.BaseHackTheBoxAPIURL + "/sherlocks?state=retired" - ScheduledSherlocksURL = config.BaseHackTheBoxAPIURL + "/sherlocks?state=unreleased" + SherlocksURL = config.BaseHackTheBoxAPIURL + "/sherlocks?state=active&sort_by=release_date&sort_type=desc&per_page=20" + RetiredSherlocksURL = config.BaseHackTheBoxAPIURL + "/sherlocks?state=retired&sort_by=release_date&sort_type=desc&per_page=20" + ScheduledSherlocksURL = config.BaseHackTheBoxAPIURL + "/sherlocks?state=unreleased&sort_by=release_date&sort_type=desc&per_page=20" ActiveSherlocksTitle = "Active" RetiredSherlocksTitle = "Retired" ScheduledSherlocksTitle = "Scheduled" @@ -36,6 +39,101 @@ func GetColorFromDifficultyText(difficultyText string) string { } } +func getStringField(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + value, ok := data[key] + if !ok || value == nil { + continue + } + + switch typed := value.(type) { + case string: + if typed != "" { + return typed + } + case fmt.Stringer: + text := typed.String() + if text != "" { + return text + } + } + } + + return "" +} + +func getIntField(data map[string]interface{}, keys ...string) (int, bool) { + for _, key := range keys { + value, ok := data[key] + if !ok || value == nil { + continue + } + + switch typed := value.(type) { + case int: + return typed, true + case int32: + return int(typed), true + case int64: + return int(typed), true + case float32: + return int(typed), true + case float64: + return int(typed), true + case string: + parsed, err := strconv.Atoi(typed) + if err == nil { + return parsed, true + } + } + } + + return 0, false +} + +func getProgressLabel(data map[string]interface{}) string { + progress, ok := getIntField(data, "progress") + if ok { + return fmt.Sprintf("%d%%", progress) + } + + return "Unknown" +} + +func getReleaseDateLabel(data map[string]interface{}) string { + releaseDate := getStringField(data, "release_date") + if releaseDate == "" { + return "Unknown" + } + + parsedDate, err := time.Parse(time.RFC3339Nano, releaseDate) + if err != nil { + parsedDate, err = time.Parse(time.RFC3339, releaseDate) + if err != nil { + return releaseDate + } + } + + return parsedDate.Format("02 January 2006") +} + +func fitColumn(value string, width int) string { + if width <= 0 { + return "" + } + + trimmed := value + if len(trimmed) > width { + if width <= 3 { + trimmed = utils.TruncateString(trimmed, width) + } else { + trimmed = utils.TruncateString(trimmed, width-3) + "..." + } + } + + return fmt.Sprintf("%-*s", width, trimmed) +} + // CreateFlex creates and returns a Flex view with machine information func CreateFlex(info interface{}, title string, isScheduled bool) (*tview.Flex, error) { config.GlobalConfig.Logger.Debug(fmt.Sprintf("Info: %v", info)) @@ -45,48 +143,37 @@ func CreateFlex(info interface{}, title string, isScheduled bool) (*tview.Flex, for _, value := range info.([]interface{}) { data := value.(map[string]interface{}) - // Determining the color according to difficulty + difficulty := getStringField(data, "difficulty", "difficultyText") + if difficulty == "" { + difficulty = "Undefined" + } + color := GetColorFromDifficultyText(difficulty) + + name := getStringField(data, "name") + if name == "" { + name = "Undefined" + } + + category := getStringField(data, "category_name") + if category == "" { + category = "Unknown" + } + + progress := getProgressLabel(data) + releaseDate := getReleaseDateLabel(data) + nameColumn := fitColumn(name, 22) + categoryColumn := fitColumn(category, 16) + difficultyColumn := fitColumn(difficulty, 12) + progressColumn := fitColumn(progress, 10) + releaseDateColumn := fitColumn(releaseDate, 18) + + formatString := fmt.Sprintf("%s %s %s%s[-] %s %s", + nameColumn, categoryColumn, color, difficultyColumn, progressColumn, releaseDateColumn) - key := "Undefined" - if title == "Scheduled" { - key = data["difficulty"].(string) + if isScheduled { + formatString = fmt.Sprintf("%s %s %s%s[-] %s %s", + nameColumn, categoryColumn, color, difficultyColumn, fitColumn("Unreleased", 10), releaseDateColumn) } - color := GetColorFromDifficultyText(key) - - // var formatString string - - // Choice of display format depending on the nature of the information - // if isScheduled { - formatString := fmt.Sprintf("%-15s %s%-10s[-]", - data["name"], color, data["difficulty"]) - //} - // else { - - // Convert and format date - // parsedDate, err := time.Parse(time.RFC3339Nano, data["release"].(string)) - // if err != nil { - // return nil, fmt.Errorf("error parsing date: %v", err) - // } - // formattedDate := parsedDate.Format("02 January 2006") - - // userEmoji := SherlocksCrossMark + "User" - // if value, ok := data["authUserInUserOwns"]; ok && value != nil { - // if value.(bool) { - // userEmoji = SherlocksCheckMark + "User" - // } - // } - - // rootEmoji := SherlocksCrossMark + "Root" - // if value, ok := data["authUserInRootOwns"]; ok && value != nil { - // if value.(bool) { - // rootEmoji = SherlocksCheckMark + "Root" - // } - // } - - // formatString = fmt.Sprintf("%-15s %s%-10s[-] %-5v %-5v %-7v %-30s", - // data["name"], color, data["difficultyText"], - // data["star"], userEmoji, rootEmoji, formattedDate) - // } flex.AddItem(tview.NewTextView().SetText(formatString).SetDynamicColors(true), 1, 0, false) }