1use std::sync::Arc;
2use std::time::Duration;
3
4use clap::Subcommand;
5
6use crate::api::HtbClient;
7use crate::cache::Cache;
8use crate::output::OutputFormat;
9
10const CTF_BASE_URL: &str = "https://ctf.hackthebox.com";
11
12#[derive(Subcommand)]
13#[command(
14 after_help = "Examples:\n htb ctf auth login Save CTF API token\n htb ctf events List CTF events\n htb ctf info ctf-try-out-1434 Event details\n htb ctf challenges 1434 Challenges in event\n htb ctf submit 31855 'HTB{flag}' Submit a flag\n htb ctf scoreboard 1434 Event scoreboard"
15)]
16pub enum CtfCommand {
17 Auth {
19 #[command(subcommand)]
20 command: CtfAuthCommand,
21 },
22 Events {
24 #[arg(long, help = "Include past events")]
25 all: bool,
26 },
27 Info {
29 slug: String,
31 },
32 Challenges {
34 event_id: u64,
36 #[arg(long, help = "Filter by difficulty")]
37 difficulty: Option<String>,
38 },
39 Submit {
41 challenge_id: u64,
43 flag: String,
45 },
46 Download {
48 event_id: u64,
50 challenge_id: u64,
52 },
53 Start {
55 event_id: u64,
57 challenge_id: u64,
59 },
60 Stop {
62 event_id: u64,
64 challenge_id: u64,
66 },
67 Scoreboard {
69 event_id: u64,
71 },
72 Solves {
74 event_id: u64,
76 },
77 ChallengeSolves {
79 challenge_id: u64,
81 },
82}
83
84#[derive(Subcommand)]
85pub enum CtfAuthCommand {
86 Login,
88 Status,
90 Logout,
92}
93
94pub async fn handle(
95 cmd: CtfCommand,
96 format: OutputFormat,
97 cache: &Arc<Cache>,
98) -> anyhow::Result<()> {
99 match cmd {
100 CtfCommand::Auth { command } => handle_auth(command, format, cache).await,
101 CtfCommand::Events { all } => events(all, format, cache).await,
102 CtfCommand::Info { slug } => info(&slug, format, cache).await,
103 CtfCommand::Challenges {
104 event_id,
105 difficulty,
106 } => challenges(event_id, difficulty.as_deref(), format, cache).await,
107 CtfCommand::Submit { challenge_id, flag } => submit(challenge_id, &flag, cache).await,
108 CtfCommand::Download {
109 event_id,
110 challenge_id,
111 } => download(event_id, challenge_id, cache).await,
112 CtfCommand::Start {
113 event_id,
114 challenge_id,
115 } => start(event_id, challenge_id, cache).await,
116 CtfCommand::Stop {
117 event_id,
118 challenge_id,
119 } => stop(event_id, challenge_id, cache).await,
120 CtfCommand::Scoreboard { event_id } => scoreboard(event_id, format, cache).await,
121 CtfCommand::Solves { event_id } => solves(event_id, format, cache).await,
122 CtfCommand::ChallengeSolves { challenge_id } => {
123 challenge_solves(challenge_id, format, cache).await
124 }
125 }
126}
127
128fn ctf_client(cache: &Arc<Cache>) -> anyhow::Result<HtbClient> {
129 let token = crate::config::read_ctf_token()?;
130 Ok(HtbClient::with_base_url_and_cache(
131 token,
132 CTF_BASE_URL.to_string(),
133 Arc::clone(cache),
134 ))
135}
136
137async fn handle_auth(
138 cmd: CtfAuthCommand,
139 format: OutputFormat,
140 cache: &Arc<Cache>,
141) -> anyhow::Result<()> {
142 match cmd {
143 CtfAuthCommand::Login => login(cache).await,
144 CtfAuthCommand::Status => status(format).await,
145 CtfAuthCommand::Logout => logout(cache),
146 }
147}
148
149async fn login(cache: &Cache) -> anyhow::Result<()> {
150 println!("Enter your CTF API token (from https://ctf.hackthebox.com/settings):");
151 let token = rpassword::read_password()?;
152 let token = token.trim().to_string();
153
154 if token.is_empty() {
155 anyhow::bail!("Token cannot be empty");
156 }
157
158 let client = HtbClient::with_base_url(token.clone(), CTF_BASE_URL.to_string());
159 let user = client.ctf().profile().await?;
160
161 crate::config::save_ctf_token(&token)?;
162 cache.clear();
163 println!("Authenticated as {}", user.name);
164 Ok(())
165}
166
167async fn status(format: OutputFormat) -> anyhow::Result<()> {
168 let token = crate::config::read_ctf_token()?;
169 let client = HtbClient::with_base_url(token, CTF_BASE_URL.to_string());
170 let user = client.ctf().profile().await?;
171
172 let fields = vec![
173 ("Username", user.name.clone()),
174 ("ID", user.id.to_string()),
175 ("Email", user.email.clone().unwrap_or_default()),
176 ("Timezone", user.timezone.clone().unwrap_or_default()),
177 (
178 "Has Team",
179 if user.has_any_team { "Yes" } else { "No" }.into(),
180 ),
181 ];
182
183 crate::output::print_detail(&user, format, &fields);
184 Ok(())
185}
186
187fn logout(cache: &Cache) -> anyhow::Result<()> {
188 crate::config::remove_ctf_token()?;
189 cache.clear();
190 println!("CTF token removed.");
191 Ok(())
192}
193
194async fn events(all: bool, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
195 let client = ctf_client(cache)?;
196 let mut events = client.ctf().events().await?;
197
198 if !all {
199 events.retain(|e| {
200 e.status
201 .as_deref()
202 .is_some_and(|s| s == "Ongoing" || s == "Upcoming")
203 });
204 }
205
206 crate::output::print_list(&events, format);
207 Ok(())
208}
209
210async fn info(slug: &str, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
211 let client = ctf_client(cache)?;
212 let detail = client.ctf().event_details(slug).await?;
213
214 let fields = vec![
215 ("ID", detail.id.to_string()),
216 ("Name", detail.name.clone()),
217 ("Status", detail.status.clone().unwrap_or_default()),
218 ("Format", detail.format.clone().unwrap_or_default()),
219 ("Type", detail.event_type.clone().unwrap_or_default()),
220 ("Location", detail.location.clone().unwrap_or_default()),
221 ("Start", detail.start_date.clone().unwrap_or_default()),
222 ("End", detail.end_date.clone().unwrap_or_default()),
223 (
224 "Players",
225 detail
226 .players_joined
227 .map(|p| p.to_string())
228 .unwrap_or_default(),
229 ),
230 (
231 "Teams",
232 detail
233 .teams_joined
234 .map(|t| t.to_string())
235 .unwrap_or_default(),
236 ),
237 (
238 "Challenges",
239 detail.challenges.map(|c| c.to_string()).unwrap_or_default(),
240 ),
241 (
242 "Max Team Size",
243 detail
244 .max_team_size
245 .map(|m| m.to_string())
246 .unwrap_or_default(),
247 ),
248 (
249 "Prize Pool",
250 detail.prize_pool.clone().unwrap_or_else(|| "-".into()),
251 ),
252 ];
253
254 crate::output::print_detail(&detail, format, &fields);
255 Ok(())
256}
257
258async fn challenges(
259 event_id: u64,
260 difficulty: Option<&str>,
261 format: OutputFormat,
262 cache: &Arc<Cache>,
263) -> anyhow::Result<()> {
264 let client = ctf_client(cache)?;
265 let data = client.ctf().event_data(event_id).await?;
266
267 let mut challenges = data.challenges;
268
269 if let Some(diff_filter) = difficulty {
270 challenges.retain(|c| {
271 c.difficulty
272 .as_deref()
273 .is_some_and(|d| d.eq_ignore_ascii_case(diff_filter))
274 });
275 }
276
277 if let Some(team) = &data.participating_team {
278 crate::output::print_message(&format!(
279 "Team: {} | Rank: {} | Solved: {}/{} | Points: {}",
280 team.name,
281 team.rank
282 .map(|r| r.to_string())
283 .unwrap_or_else(|| "-".into()),
284 team.solved_challenges.unwrap_or(0),
285 team.total_challenges.unwrap_or(0),
286 team.points.unwrap_or(0),
287 ));
288 }
289
290 crate::output::print_list(&challenges, format);
291 Ok(())
292}
293
294async fn submit(challenge_id: u64, flag: &str, cache: &Arc<Cache>) -> anyhow::Result<()> {
295 let client = ctf_client(cache)?;
296 let result = client.ctf().submit_flag(challenge_id, flag).await?;
297 if let Some(points) = result.points {
298 crate::output::print_message(&format!("{} (+{} points)", result.message, points));
299 } else {
300 crate::output::print_message(&result.message);
301 }
302 Ok(())
303}
304
305async fn download(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
306 let client = ctf_client(cache)?;
307
308 let data = client.ctf().event_data(event_id).await?;
309 let challenge = data
310 .challenges
311 .iter()
312 .find(|c| c.id == challenge_id)
313 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
314
315 let filename = match &challenge.filename {
316 Some(f) => f.clone(),
317 None => anyhow::bail!("No files available for this challenge."),
318 };
319
320 let bytes = client.ctf().download_file(challenge_id).await?;
321 let safe_name = crate::sanitize_filename(&filename, &format!("{challenge_id}.zip"));
322 std::fs::write(&safe_name, &bytes)?;
323 crate::output::print_message(&format!("Downloaded {} ({} bytes)", safe_name, bytes.len()));
324 Ok(())
325}
326
327async fn start(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
328 let client = ctf_client(cache)?;
329
330 let data = client.ctf().event_data(event_id).await?;
331 let challenge = data
332 .challenges
333 .iter()
334 .find(|c| c.id == challenge_id)
335 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
336
337 if challenge.has_docker.unwrap_or(0) == 0 {
338 anyhow::bail!("This challenge doesn't use a container.");
339 }
340
341 let resp = client.ctf().container_start(challenge_id).await?;
342 crate::output::print_message(&resp.message);
343
344 for _ in 0..15 {
346 tokio::time::sleep(Duration::from_secs(2)).await;
347 let poll = client.ctf().event_data(event_id).await?;
348 if let Some(c) = poll.challenges.iter().find(|c| c.id == challenge_id) {
349 if let Some(ref addr) = c.docker_online {
350 let port_info = c.docker_ports.as_deref().unwrap_or("");
351 if port_info.is_empty() {
352 crate::output::print_message(&format!("Ready: {addr}"));
353 } else {
354 crate::output::print_message(&format!("Ready: {addr}:{port_info}"));
355 }
356 return Ok(());
357 }
358 }
359 }
360
361 crate::output::print_message("Container started but not ready yet. Check back shortly.");
362 Ok(())
363}
364
365async fn stop(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
366 let client = ctf_client(cache)?;
367
368 let data = client.ctf().event_data(event_id).await?;
369 let challenge = data
370 .challenges
371 .iter()
372 .find(|c| c.id == challenge_id)
373 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
374
375 if challenge.has_docker.unwrap_or(0) == 0 {
376 anyhow::bail!("This challenge doesn't use a container.");
377 }
378
379 let resp = client.ctf().container_stop(challenge_id).await?;
380 crate::output::print_message(&resp.message);
381 Ok(())
382}
383
384async fn scoreboard(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
385 let client = ctf_client(cache)?;
386
387 let menu = client.ctf().menu(event_id).await?;
388 if menu.user_can_view_scoreboard == Some(0) {
389 anyhow::bail!("Scoreboard is hidden for this event.");
390 }
391
392 let sb = client.ctf().scoreboard(event_id).await?;
393
394 if let Some(team) = &sb.participating_team {
395 crate::output::print_message(&format!(
396 "Your team: {} | Rank: {} | Points: {} | Flags: {} | Bloods: {}",
397 team.name,
398 team.position
399 .map(|p| p.to_string())
400 .unwrap_or_else(|| "-".into()),
401 team.points.unwrap_or(0),
402 team.owned_flags.unwrap_or(0),
403 team.first_bloods.unwrap_or(0),
404 ));
405 }
406
407 let scores: Vec<_> = sb
409 .scores
410 .iter()
411 .enumerate()
412 .map(|(i, s)| RankedScore {
413 rank: i as u32 + 1,
414 score: s,
415 })
416 .collect();
417
418 crate::output::print_list(&scores, format);
419 Ok(())
420}
421
422struct RankedScore<'a> {
423 rank: u32,
424 score: &'a crate::models::ctf::CtfTeamScore,
425}
426
427impl serde::Serialize for RankedScore<'_> {
428 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
429 self.score.serialize(serializer)
430 }
431}
432
433impl crate::output::Tabular for RankedScore<'_> {
434 fn headers() -> Vec<&'static str> {
435 vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
436 }
437
438 fn row(&self) -> Vec<String> {
439 vec![
440 self.rank.to_string(),
441 self.score.name.clone(),
442 self.score.country_code.clone().unwrap_or_default(),
443 self.score.points.map(|p| p.to_string()).unwrap_or_default(),
444 self.score
445 .owned_flags
446 .map(|f| f.to_string())
447 .unwrap_or_default(),
448 self.score
449 .first_bloods
450 .map(|b| b.to_string())
451 .unwrap_or_default(),
452 ]
453 }
454}
455
456async fn solves(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
457 let client = ctf_client(cache)?;
458 let solves = client.ctf().solves(event_id).await?;
459 crate::output::print_list(&solves, format);
460 Ok(())
461}
462
463async fn challenge_solves(
464 challenge_id: u64,
465 format: OutputFormat,
466 cache: &Arc<Cache>,
467) -> anyhow::Result<()> {
468 let client = ctf_client(cache)?;
469 let solves = client.ctf().challenge_solves(challenge_id).await?;
470 crate::output::print_list(&solves, format);
471 Ok(())
472}