Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions cmd/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,19 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string
config.GlobalConfig.Logger.Debug(fmt.Sprintf("InfoKey: %s", infoKey))

info := utils.ParseJsonMessage(resp, infoKey)
data := info.(map[string]interface{})
data, ok := info.(map[string]interface{})
if !ok {
return fmt.Errorf("could not read '%s' data from the HTB API (endpoint may have changed upstream)", infoKey)
}

endpoints := []struct {
name string
url string
}{
{"Fortresses", "/user/profile/progress/fortress/"},
{"Prolabs", "/user/profile/progress/prolab/"},
{"Activity", "/user/profile/activity/"},
// "Activity" (/user/profile/activity/) was removed from the HTB API v4
// and now always returns 404, so it is no longer requested.
}

dataMaps := make(map[string]map[string]interface{})
Expand Down Expand Up @@ -122,6 +126,10 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string
}
bodyData = fmt.Sprintf("%v\t%v\t%v\t%v\t%v\t%v\t%v\t%v\n", data["name"], data["category_name"], retiredStatus, data["difficulty"], data["stars"], data["solves"], status, datetime)
} else if elementType == "Username" {
if !stdoutIsTerminal() {
printUserProfilePlain(data, dataMaps)
return nil
}
utils.DisplayInformationsGUI(data, dataMaps)
return nil
}
Expand All @@ -132,6 +140,46 @@ func fetchAndDisplayInfo(url, header string, params []string, elementType string
return nil
}

// printUserProfilePlain renders a user profile as plain text for non-TTY output.
func printUserProfilePlain(profile map[string]interface{}, dataMaps map[string]map[string]interface{}) {
fmt.Printf("%-18s: %v\n", "Name", profile["name"])
fmt.Printf("%-18s: %v (%v)\n", "Rank", profile["rank"], profile["points"])
fmt.Printf("%-18s: %v\n", "Global Rank", profile["ranking"])
fmt.Printf("%-18s: %v\n", "User Owns", profile["user_owns"])
fmt.Printf("%-18s: %v\n", "System Owns", profile["system_owns"])
fmt.Printf("%-18s: %v / %v\n", "User Bloods", profile["user_bloods"], profile["system_bloods"])
fmt.Printf("%-18s: %v%% (req %v)\n", "Rank Ownership", profile["rank_ownership"], profile["rank_requirement"])
fmt.Printf("%-18s: %v\n", "Country", profile["country_name"])
if team, ok := profile["team"].(map[string]interface{}); ok && team != nil {
fmt.Printf("%-18s: %v (#%v)\n", "Team", team["name"], team["ranking"])
} else {
fmt.Printf("%-18s: %v\n", "Team", "N/A")
}
fmt.Printf("%-18s: %v\n", "Subscription", profile["labs_membership"])

for _, section := range []struct{ key, label, itemKey string }{
{"Fortresses", "Fortresses", "fortresses"},
{"Prolabs", "Pro Labs", "prolabs"},
} {
sectionData, ok := dataMaps[section.key]
if !ok {
continue
}
items, ok := sectionData[section.itemKey].([]interface{})
if !ok || len(items) == 0 {
continue
}
fmt.Printf("\n%s:\n", section.label)
for _, item := range items {
entry, ok := item.(map[string]interface{})
if !ok {
continue
}
fmt.Printf(" - %v (%v/%v)\n", entry["name"], entry["owned_flags"], entry["total_flags"])
}
}
}

// coreInfoCmd is the core of the info command; it checks the parameters and displays corresponding information.
func coreInfoCmd(machineName []string, challengeName []string, usernameName []string) error {
machineHeader := "Name\tOS\tRetired\tDifficulty\tStars\tIP\tStatus\tLast Reset\tRelease"
Expand Down
153 changes: 126 additions & 27 deletions cmd/machines.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,80 @@ const (
CrossMark = "\U0000274C"
Penguin = "\U0001F427"
Computer = "\U0001F5A5 "

scheduledUnavailableMsg = "No scheduled machine data available (this API endpoint is currently disabled/renamed upstream)"
)

// stdoutIsTerminal reports whether stdout is attached to a real terminal.
// TUI rendering requires a TTY; without one we fall back to plain text.
func stdoutIsTerminal() bool {
fileInfo, err := os.Stdout.Stat()
if err != nil {
return false
}
return fileInfo.Mode()&os.ModeCharDevice != 0
}

// printMachinesPlain renders the machine lists as plain text for non-TTY output.
func printMachinesPlain() {
sections := []struct {
url string
title string
scheduled bool
}{
{machineURL, activeTitle, false},
{retiredURL, retiredTitle, false},
{scheduledURL, scheduledTitle, true},
}

for _, section := range sections {
resp, err := utils.HtbRequest(http.MethodGet, section.url, nil)
fmt.Printf("\n== %s ==\n", section.title)
if err != nil {
fmt.Printf(" unavailable: %v\n", err)
continue
}

info := utils.ParseJsonMessage(resp, "data")
resp.Body.Close()

items, ok := info.([]interface{})
if !ok || len(items) == 0 {
fmt.Printf(" %s\n", scheduledUnavailableMsg)
continue
}

for _, value := range items {
data, ok := value.(map[string]interface{})
if !ok {
continue
}
name := getStr(data, "name")
osName := getStr(data, "os")
difficulty := getStr(data, "difficultyText")
if section.scheduled {
difficulty = getStr(data, "difficulty_text")
fmt.Printf(" %-20s %-8s %s\n", name, osName, difficulty)
continue
}

userOwned, rootOwned := " -", " -"
if getBool(data, "authUserInUserOwns") {
userOwned = "user"
}
if getBool(data, "authUserInRootOwns") {
rootOwned = "root"
}
release := getStr(data, "release")
if parsed, err := time.Parse(time.RFC3339Nano, release); err == nil {
release = parsed.Format("2006-01-02")
}
fmt.Printf(" %-20s %-8s %-8s user:%-4s root:%-4s %s\n",
name, osName, difficulty, userOwned, rootOwned, release)
}
}
}

// getColorFromDifficultyText returns the color corresponding to the given difficulty.
func getColorFromDifficultyText(difficultyText string) string {
switch difficultyText {
Expand Down Expand Up @@ -54,56 +126,75 @@ func getOSEmoji(os string) string {
}
}

// getStr safely extracts a string field from a decoded JSON object.
func getStr(data map[string]interface{}, key string) string {
if value, ok := data[key].(string); ok {
return value
}
return ""
}

// getBool safely extracts a boolean field from a decoded JSON object.
func getBool(data map[string]interface{}, key string) bool {
if value, ok := data[key].(bool); ok {
return value
}
return false
}

// createFlex creates and returns a Flex view with machine information
func createFlex(info interface{}, title string, isScheduled bool) (*tview.Flex, error) {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.SetBorder(true).SetTitle(title).SetTitleAlign(tview.AlignLeft)

for _, value := range info.([]interface{}) {
data := value.(map[string]interface{})
items, ok := info.([]interface{})
if !ok || len(items) == 0 {
flex.AddItem(tview.NewTextView().SetText(scheduledUnavailableMsg), 1, 0, false)
return flex, nil
}

// Determining the color according to difficulty
for _, value := range items {
data, ok := value.(map[string]interface{})
if !ok {
continue
}

key := "Undefined"
_ = key
if title == "Scheduled" {
key = data["difficulty_text"].(string)
} else {
key = data["difficultyText"].(string)
// Determining the color according to difficulty
key := getStr(data, "difficultyText")
if isScheduled {
key = getStr(data, "difficulty_text")
}
color := getColorFromDifficultyText(key)
osEmoji := getOSEmoji(data["os"].(string))
osName := getStr(data, "os")
osEmoji := getOSEmoji(osName)

var formatString string

// Choice of display format depending on the nature of the information
if isScheduled {
formatString = fmt.Sprintf("%-10s %s%-10s %s%-10s[-]",
data["name"], osEmoji, data["os"], color, data["difficulty_text"])
getStr(data, "name"), osEmoji, osName, color, getStr(data, "difficulty_text"))
} 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)
// Convert and format date (tolerate unexpected/missing values)
formattedDate := "unknown"
if release := getStr(data, "release"); release != "" {
if parsedDate, err := time.Parse(time.RFC3339Nano, release); err == nil {
formattedDate = parsedDate.Format("02 January 2006")
}
}
formattedDate := parsedDate.Format("02 January 2006")

userEmoji := CrossMark + "User"
if value, ok := data["authUserInUserOwns"]; ok && value != nil {
if value.(bool) {
userEmoji = CheckMark + "User"
}
if getBool(data, "authUserInUserOwns") {
userEmoji = CheckMark + "User"
}

rootEmoji := CrossMark + "Root"
if value, ok := data["authUserInRootOwns"]; ok && value != nil {
if value.(bool) {
rootEmoji = CheckMark + "Root"
}
if getBool(data, "authUserInRootOwns") {
rootEmoji = CheckMark + "Root"
}

formatString = fmt.Sprintf("%-15s %s%-10s %s%-10s[-] %-5v %-5v %-7v %-30s",
data["name"], osEmoji, data["os"], color, data["difficultyText"],
getStr(data, "name"), osEmoji, osName, color, getStr(data, "difficultyText"),
data["star"], userEmoji, rootEmoji, formattedDate)
}

Expand All @@ -117,6 +208,13 @@ var machinesCmd = &cobra.Command{
Use: "machines",
Short: "Displays active / retired machines and next machines to be released",
Run: func(cmd *cobra.Command, args []string) {
// The scheduled-machines endpoint has been retired upstream, so this
// view must degrade gracefully instead of panicking on nil data.
if !stdoutIsTerminal() {
printMachinesPlain()
return
}

app := tview.NewApplication()

getAndDisplayFlex := func(url, title string, isScheduled bool, flex *tview.Flex) error {
Expand Down Expand Up @@ -150,8 +248,9 @@ var machinesCmd = &cobra.Command{
}

if err := getAndDisplayFlex(scheduledURL, scheduledTitle, true, rightFlex); err != nil {
config.GlobalConfig.Logger.Error("", zap.Error(err))
os.Exit(1)
// Endpoint removed upstream -> show a notice instead of exiting.
config.GlobalConfig.Logger.Warn("scheduled machines unavailable", zap.Error(err))
rightFlex.AddItem(tview.NewTextView().SetText(scheduledUnavailableMsg), 1, 0, false)
}

rightFlex.AddItem(tview.NewTextView().SetText("").SetDynamicColors(true), 0, 0, false)
Expand Down
51 changes: 51 additions & 0 deletions cmd/sherlocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,50 @@ import (
"go.uber.org/zap"
)

// printSherlocksPlain renders Sherlock lists as plain text for non-TTY output.
func printSherlocksPlain() {
sections := []struct {
url string
title string
}{
{sherlocks.SherlocksURL, sherlocks.ActiveSherlocksTitle},
{sherlocks.RetiredSherlocksURL, sherlocks.RetiredSherlocksTitle},
{sherlocks.ScheduledSherlocksURL, sherlocks.ScheduledSherlocksTitle},
}

for _, section := range sections {
fmt.Printf("\n== %s ==\n", section.title)
resp, err := utils.HtbRequest(http.MethodGet, section.url, nil)
if err != nil {
fmt.Printf(" unavailable: %v\n", err)
continue
}

info := utils.ParseJsonMessage(resp, "data")
resp.Body.Close()

items, ok := info.([]interface{})
if !ok || len(items) == 0 {
fmt.Println(" (no data)")
continue
}

for _, value := range items {
data, ok := value.(map[string]interface{})
if !ok {
continue
}
owned := " -"
if getBool(data, "is_owned") {
owned = "own"
}
fmt.Printf(" %-28s %-10s %-6s solves:%-6v rating:%-4v %s\n",
getStr(data, "name"), getStr(data, "difficulty"), owned,
data["solves"], data["rating"], getStr(data, "category_name"))
}
}
}

var sherlocksCmd = &cobra.Command{
Use: "sherlocks",
Short: "Play Sherlocks mode (blue team)",
Expand Down Expand Up @@ -79,6 +123,13 @@ var sherlocksCmd = &cobra.Command{
}
return
}

// Listing Sherlocks requires a TUI; without a TTY print plain text instead.
if !stdoutIsTerminal() {
printSherlocksPlain()
return
}

app := tview.NewApplication()

getAndDisplayFlex := func(url, title string, isScheduled bool, flex *tview.Flex) error {
Expand Down
19 changes: 16 additions & 3 deletions lib/utils/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ func displayInfo(dataMaps map[string]map[string]interface{}, dataMapKey string,
return displayInfoPanel(title, items, formatterFunc, paddingBottom)
}

// placeholderPanel is returned when an API section is unavailable, so the TUI
// never receives a nil primitive (which would segfault while drawing).
func placeholderPanel(title, message string) *tview.Flex {
panel := tview.NewFlex().SetDirection(tview.FlexRow)
panel.SetBorder(true).SetTitle(title).SetTitleAlign(tview.AlignLeft)
panel.AddItem(tview.NewTextView().SetText(message), 1, 0, false)
return panel
}

// Initializes and displays user information in tview
func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[string]map[string]interface{}) {

Expand Down Expand Up @@ -175,7 +184,12 @@ func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[

fortressesPanel := displayInfo(advancedLabsMap, "fortresses", "Fortresses", "\U0001F3F0", maxFortressNameLength, 4)
prolabsPanel := displayInfo(advancedLabsMap, "prolabs", "Pro Labs", "\U0001F47D", maxProlabNameLength, 4)
activityPanel := displayInfo(advancedLabsMap, "activity", "Activity", "", maxActivityNameLength, 3)
if fortressesPanel == nil {
fortressesPanel = placeholderPanel("Fortresses", "No fortress data available")
}
if prolabsPanel == nil {
prolabsPanel = placeholderPanel("Pro Labs", "No Pro Lab data available")
}

advancedLabsFlex := tview.NewFlex().
SetDirection(tview.FlexRow).
Expand All @@ -186,8 +200,7 @@ func DisplayInformationsGUI(profile map[string]interface{}, advancedLabsMap map[
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)

mainFlex := tview.NewFlex().
AddItem(leftFlex, 0, 2, false)
Expand Down