1use serde::{Deserialize, Serialize};
2
3use crate::output::Tabular;
4
5#[derive(Debug, Deserialize, Serialize)]
6pub struct CtfUserProfile {
7 pub id: u64,
8 pub name: String,
9 #[serde(default)]
10 pub full_name: Option<String>,
11 #[serde(default)]
12 pub email: Option<String>,
13 #[serde(default)]
14 pub timezone: Option<String>,
15 #[serde(default, rename = "hasAnyTeam")]
16 pub has_any_team: bool,
17 #[serde(default)]
18 pub avatar: Option<String>,
19}
20
21impl Tabular for CtfUserProfile {
22 fn headers() -> Vec<&'static str> {
23 vec!["ID", "Name", "Email", "Timezone"]
24 }
25
26 fn row(&self) -> Vec<String> {
27 vec![
28 self.id.to_string(),
29 self.name.clone(),
30 self.email.clone().unwrap_or_default(),
31 self.timezone.clone().unwrap_or_default(),
32 ]
33 }
34}
35
36#[derive(Debug, Deserialize, Serialize)]
37pub struct CtfEvent {
38 pub id: u64,
39 pub name: String,
40 #[serde(default)]
41 pub slug: Option<String>,
42 #[serde(default)]
43 pub status: Option<String>,
44 #[serde(default)]
45 pub starts_at: Option<String>,
46 #[serde(default)]
47 pub ends_at: Option<String>,
48 #[serde(default)]
49 pub format: Option<String>,
50 #[serde(default)]
51 pub team_size: Option<u32>,
52 #[serde(default)]
53 pub players: Option<u32>,
54 #[serde(default)]
55 pub org_name: Option<String>,
56 #[serde(default, rename = "hasJoined")]
57 pub has_joined: u32,
58 #[serde(default, rename = "joinedTeam")]
59 pub joined_team: Option<String>,
60 #[serde(default, rename = "membersJoined")]
61 pub members_joined: Option<String>,
62}
63
64impl Tabular for CtfEvent {
65 fn headers() -> Vec<&'static str> {
66 vec![
67 "ID",
68 "Name",
69 "Status",
70 "Format",
71 "Players",
72 "Team Size",
73 "Joined",
74 ]
75 }
76
77 fn row(&self) -> Vec<String> {
78 vec![
79 self.id.to_string(),
80 self.name.clone(),
81 self.status.clone().unwrap_or_default(),
82 self.format.clone().unwrap_or_default(),
83 self.players.map(|p| p.to_string()).unwrap_or_default(),
84 self.team_size.map(|t| t.to_string()).unwrap_or_default(),
85 if self.has_joined > 0 {
86 self.joined_team.clone().unwrap_or_else(|| "Yes".into())
87 } else {
88 String::new()
89 },
90 ]
91 }
92}
93
94#[derive(Debug, Deserialize, Serialize)]
95pub struct CtfEventDetail {
96 pub id: u64,
97 pub name: String,
98 #[serde(default)]
99 pub slug: Option<String>,
100 #[serde(default)]
101 pub status: Option<String>,
102 #[serde(default, rename = "startDate")]
103 pub start_date: Option<String>,
104 #[serde(default, rename = "endDate")]
105 pub end_date: Option<String>,
106 #[serde(default)]
107 pub description: Option<String>,
108 #[serde(default, rename = "type")]
109 pub event_type: Option<String>,
110 #[serde(default)]
111 pub format: Option<String>,
112 #[serde(default)]
113 pub location: Option<String>,
114 #[serde(default, rename = "playersJoined")]
115 pub players_joined: Option<u32>,
116 #[serde(default, rename = "teamsJoined")]
117 pub teams_joined: Option<u32>,
118 #[serde(default)]
119 pub challenges: Option<u32>,
120 #[serde(default, rename = "challengeCategories")]
121 pub challenge_categories: Option<u32>,
122 #[serde(default, rename = "maxTeamSize")]
123 pub max_team_size: Option<u32>,
124 #[serde(default, rename = "prizePool")]
125 pub prize_pool: Option<String>,
126 #[serde(default)]
127 pub featured: bool,
128 #[serde(default)]
129 pub ai_usage_policy: Option<String>,
130}
131
132#[derive(Debug, Deserialize, Serialize)]
133pub struct CtfEventData {
134 pub id: u64,
135 pub name: String,
136 #[serde(default)]
137 pub status: Option<String>,
138 #[serde(default)]
139 pub hide_scoreboard: u32,
140 #[serde(default)]
141 pub participating_team: Option<CtfParticipatingTeam>,
142 #[serde(default)]
143 pub challenges: Vec<CtfChallenge>,
144}
145
146#[derive(Debug, Deserialize, Serialize)]
147pub struct CtfParticipatingTeam {
148 pub id: u64,
149 pub name: String,
150 #[serde(default)]
151 pub points: Option<u32>,
152 #[serde(default)]
153 pub solved_challenges: Option<u32>,
154 #[serde(default)]
155 pub total_challenges: Option<u32>,
156 #[serde(default)]
157 pub owned_flags: Option<u32>,
158 #[serde(default)]
159 pub total_flags: Option<u32>,
160 #[serde(default)]
161 pub rank: Option<u32>,
162 #[serde(default, rename = "isCaptain")]
163 pub is_captain: bool,
164}
165
166#[derive(Debug, Deserialize, Serialize, Clone)]
167pub struct CtfChallenge {
168 pub id: u64,
169 pub name: String,
170 #[serde(default)]
171 pub creator: Option<String>,
172 #[serde(default)]
173 pub description: Option<String>,
174 #[serde(default)]
175 pub challenge_category_id: Option<u64>,
176 #[serde(default)]
177 pub difficulty: Option<String>,
178 #[serde(default)]
179 pub filename: Option<String>,
180 #[serde(default, rename = "hasDocker")]
181 pub has_docker: Option<u32>,
182 #[serde(default)]
183 pub docker_online: Option<String>,
184 #[serde(default)]
185 pub docker_ports: Option<String>,
186 #[serde(default)]
187 pub points: Option<u32>,
188 #[serde(default)]
189 pub solves: Option<u32>,
190 #[serde(default)]
191 pub solved: bool,
192 #[serde(default)]
193 pub status: Option<String>,
194 #[serde(default, rename = "flagsInfo")]
195 pub flags_info: Vec<CtfFlagInfo>,
196}
197
198impl Tabular for CtfChallenge {
199 fn headers() -> Vec<&'static str> {
200 vec![
201 "ID",
202 "Name",
203 "Difficulty",
204 "Points",
205 "Solves",
206 "Docker",
207 "Download",
208 "Flags",
209 "Solved",
210 ]
211 }
212
213 fn row(&self) -> Vec<String> {
214 vec![
215 self.id.to_string(),
216 self.name.clone(),
217 self.difficulty.clone().unwrap_or_default(),
218 self.points.map(|p| p.to_string()).unwrap_or_default(),
219 self.solves.map(|s| s.to_string()).unwrap_or_default(),
220 if self.has_docker.unwrap_or(0) > 0 {
221 "Yes"
222 } else {
223 ""
224 }
225 .to_string(),
226 if self.filename.is_some() { "Yes" } else { "" }.to_string(),
227 self.flags_info.len().to_string(),
228 if self.solved { "Yes" } else { "" }.to_string(),
229 ]
230 }
231}
232
233#[derive(Debug, Deserialize, Serialize, Clone)]
234pub struct CtfFlagInfo {
235 pub flag_id: u64,
236 #[serde(default)]
237 pub identifier: Option<String>,
238 #[serde(default)]
239 pub question: Option<String>,
240 #[serde(default)]
241 pub solved: bool,
242}
243
244#[derive(Debug, Deserialize, Serialize)]
245pub struct CtfMenu {
246 pub id: u64,
247 #[serde(default, rename = "userCanViewScoreboard")]
248 pub user_can_view_scoreboard: Option<u32>,
249 #[serde(default)]
250 pub status: Option<String>,
251}
252
253#[derive(Debug, Deserialize, Serialize)]
254pub struct CtfScoreboard {
255 #[serde(default)]
256 pub ctf_name: Option<String>,
257 #[serde(default)]
258 pub ctf_teams: Option<u32>,
259 #[serde(default)]
260 pub ctf_players: Option<u32>,
261 #[serde(default)]
262 pub is_ended: bool,
263 #[serde(default)]
264 pub participating_team: Option<CtfScoreboardTeam>,
265 #[serde(default)]
266 pub scores: Vec<CtfTeamScore>,
267}
268
269#[derive(Debug, Deserialize, Serialize)]
270pub struct CtfScoreboardTeam {
271 pub id: u64,
272 pub name: String,
273 #[serde(default)]
274 pub position: Option<u32>,
275 #[serde(default)]
276 pub points: Option<u32>,
277 #[serde(default)]
278 pub solved_challenges: Option<u32>,
279 #[serde(default)]
280 pub total_challenges: Option<u32>,
281 #[serde(default)]
282 pub owned_flags: Option<u32>,
283 #[serde(default)]
284 pub first_bloods: Option<u32>,
285}
286
287#[derive(Debug, Deserialize, Serialize)]
288pub struct CtfTeamScore {
289 pub id: u64,
290 pub name: String,
291 #[serde(default)]
292 pub country_code: Option<String>,
293 #[serde(default)]
294 pub points: Option<u32>,
295 #[serde(default)]
296 pub owned_flags: Option<u32>,
297 #[serde(default)]
298 pub first_bloods: Option<u32>,
299}
300
301impl Tabular for CtfTeamScore {
302 fn headers() -> Vec<&'static str> {
303 vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
304 }
305
306 fn row(&self) -> Vec<String> {
307 vec![
308 String::new(), self.name.clone(),
310 self.country_code.clone().unwrap_or_default(),
311 self.points.map(|p| p.to_string()).unwrap_or_default(),
312 self.owned_flags.map(|f| f.to_string()).unwrap_or_default(),
313 self.first_bloods.map(|b| b.to_string()).unwrap_or_default(),
314 ]
315 }
316}
317
318#[derive(Debug, Deserialize, Serialize)]
319pub struct CtfSolve {
320 #[serde(default)]
321 pub challenge_name: Option<String>,
322 #[serde(default)]
323 pub challenge_category: Option<String>,
324 #[serde(default)]
325 pub team_name: Option<String>,
326 #[serde(default)]
327 pub user_name: Option<String>,
328 #[serde(default)]
329 pub created_at: Option<String>,
330}
331
332impl Tabular for CtfSolve {
333 fn headers() -> Vec<&'static str> {
334 vec!["Challenge", "Category", "Team", "Player", "Time"]
335 }
336
337 fn row(&self) -> Vec<String> {
338 vec![
339 self.challenge_name.clone().unwrap_or_default(),
340 self.challenge_category.clone().unwrap_or_default(),
341 self.team_name.clone().unwrap_or_default(),
342 self.user_name.clone().unwrap_or_default(),
343 self.created_at.clone().unwrap_or_default(),
344 ]
345 }
346}
347
348#[derive(Debug, Deserialize, Serialize)]
349pub struct CtfChallengeSolve {
350 #[serde(default)]
351 pub team_id: Option<u64>,
352 #[serde(default)]
353 pub team_name: Option<String>,
354 #[serde(default)]
355 pub date_of_latest_own: Option<String>,
356 #[serde(default)]
357 pub user_of_latest_own: Option<CtfSolveUser>,
358}
359
360#[derive(Debug, Deserialize, Serialize)]
361pub struct CtfSolveUser {
362 pub name: String,
363}
364
365impl Tabular for CtfChallengeSolve {
366 fn headers() -> Vec<&'static str> {
367 vec!["Team", "Player", "Time"]
368 }
369
370 fn row(&self) -> Vec<String> {
371 vec![
372 self.team_name.clone().unwrap_or_default(),
373 self.user_of_latest_own
374 .as_ref()
375 .map(|u| u.name.clone())
376 .unwrap_or_default(),
377 self.date_of_latest_own.clone().unwrap_or_default(),
378 ]
379 }
380}
381
382#[derive(Debug, Deserialize, Serialize)]
383pub struct CtfFlagResult {
384 pub message: String,
385 #[serde(default)]
386 pub points: Option<u32>,
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn deserialize_ctf_events_fixture() {
395 let json = include_str!("../../tests/fixtures/ctf/events.json");
396 let events: Vec<CtfEvent> = serde_json::from_str(json).unwrap();
397 assert_eq!(events.len(), 2);
398 assert_eq!(events[0].name, "CTF Try Out");
399 assert_eq!(events[0].has_joined, 1);
400 assert_eq!(events[1].status.as_deref(), Some("Upcoming"));
401 }
402
403 #[test]
404 fn deserialize_ctf_event_detail_fixture() {
405 let json = include_str!("../../tests/fixtures/ctf/event-detail.json");
406 let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
407 assert_eq!(detail.id, 1434);
408 assert_eq!(detail.players_joined, Some(1200));
409 assert_eq!(detail.challenges, Some(50));
410 }
411
412 #[test]
413 fn deserialize_ctf_event_data_fixture() {
414 let json = include_str!("../../tests/fixtures/ctf/event-data.json");
415 let data: CtfEventData = serde_json::from_str(json).unwrap();
416 assert_eq!(data.challenges.len(), 2);
417 assert_eq!(data.challenges[0].name, "An unusual sighting");
418 assert!(data.challenges[0].solved);
419 assert!(!data.challenges[1].solved);
420 assert_eq!(data.challenges[1].has_docker, Some(1));
421 let team = data.participating_team.unwrap();
422 assert_eq!(team.rank, Some(42));
423 }
424
425 #[test]
426 fn deserialize_ctf_scoreboard_fixture() {
427 let json = include_str!("../../tests/fixtures/ctf/scoreboard.json");
428 let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
429 assert_eq!(sb.scores.len(), 2);
430 assert_eq!(sb.scores[0].points, Some(15000));
431 let team = sb.participating_team.unwrap();
432 assert_eq!(team.position, Some(42));
433 }
434
435 #[test]
436 fn deserialize_ctf_solves_fixture() {
437 let json = include_str!("../../tests/fixtures/ctf/solves.json");
438 let solves: Vec<CtfSolve> = serde_json::from_str(json).unwrap();
439 assert_eq!(solves.len(), 2);
440 assert_eq!(solves[0].challenge_name.as_deref(), Some("LootStash"));
441 }
442
443 #[test]
444 fn deserialize_ctf_user_profile_fixture() {
445 let json = include_str!("../../tests/fixtures/ctf/user-profile.json");
446 let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
447 assert_eq!(profile.name, "LANGSOMT");
448 assert!(profile.has_any_team);
449 }
450
451 #[test]
452 fn deserialize_ctf_menu_fixture() {
453 let json = include_str!("../../tests/fixtures/ctf/menu.json");
454 let menu: CtfMenu = serde_json::from_str(json).unwrap();
455 assert_eq!(menu.id, 1434);
456 assert_eq!(menu.user_can_view_scoreboard, Some(1));
457 }
458
459 #[test]
460 fn deserialize_ctf_event() {
461 let json = r#"{
462 "id": 1434,
463 "name": "CTF Try Out",
464 "slug": "ctf-try-out-1434",
465 "status": "Ongoing",
466 "starts_at": "2026-01-01T00:00:00.000000Z",
467 "ends_at": "2026-12-31T12:00:00.000000Z",
468 "format": "Jeopardy",
469 "team_mode": "user_organized",
470 "team_size": 5,
471 "players": 1200,
472 "private": 0,
473 "university": false,
474 "isBusiness": false,
475 "featured": false,
476 "banner": "https://example.com/banner.png",
477 "org_name": "Hack The Box",
478 "org_logo": "https://example.com/logo.png",
479 "canPlay": true,
480 "canJoin": true,
481 "hasJoined": 1,
482 "joinedTeam": "v1olet",
483 "membersJoined": "4/5",
484 "hide_scoreboard": 0,
485 "mcp_access_mode": "no_mcp"
486 }"#;
487 let event: CtfEvent = serde_json::from_str(json).unwrap();
488 assert_eq!(event.id, 1434);
489 assert_eq!(event.name, "CTF Try Out");
490 assert_eq!(event.status.as_deref(), Some("Ongoing"));
491 assert_eq!(event.has_joined, 1);
492 assert_eq!(event.joined_team.as_deref(), Some("v1olet"));
493 }
494
495 #[test]
496 fn deserialize_ctf_event_detail() {
497 let json = r#"{
498 "id": 1434,
499 "name": "CTF Try Out",
500 "slug": "ctf-try-out-1434",
501 "status": "Ongoing",
502 "startDate": "01 Jan 2026, 00:00 AM UTC",
503 "endDate": "31 Dec 2026, 12:00 PM UTC",
504 "description": "<p>A practice CTF</p>",
505 "type": "Public",
506 "format": "Jeopardy",
507 "location": "Online",
508 "playersJoined": 1200,
509 "teamsJoined": 300,
510 "challenges": 50,
511 "challengeCategories": 10,
512 "maxTeamSize": 5,
513 "prizePool": null,
514 "featured": false,
515 "ai_usage_policy": null
516 }"#;
517 let detail: CtfEventDetail = serde_json::from_str(json).unwrap();
518 assert_eq!(detail.id, 1434);
519 assert_eq!(detail.players_joined, Some(1200));
520 assert_eq!(detail.teams_joined, Some(300));
521 assert_eq!(detail.challenges, Some(50));
522 }
523
524 #[test]
525 fn deserialize_ctf_event_data_with_challenges() {
526 let json = r#"{
527 "id": 1434,
528 "name": "CTF Try Out",
529 "status": "Ongoing",
530 "hide_scoreboard": 0,
531 "participating_team": {
532 "id": 321013,
533 "name": "v1olet",
534 "points": 5000,
535 "solved_challenges": 10,
536 "total_challenges": 50,
537 "owned_flags": 10,
538 "total_flags": 60,
539 "rank": 42,
540 "isCaptain": false
541 },
542 "challenges": [{
543 "id": 31855,
544 "name": "Test Challenge",
545 "creator": "author",
546 "description": "A test",
547 "challenge_category_id": 2,
548 "difficulty": "Easy",
549 "filename": "test.zip",
550 "hasDocker": 1,
551 "docker_online": null,
552 "docker_ports": null,
553 "points": 500,
554 "solves": 42,
555 "solved": true,
556 "status": "in progress",
557 "flagsInfo": [
558 {"flag_id": 1, "identifier": null, "question": null, "solved": true}
559 ],
560 "machine": null,
561 "team_solves": []
562 }]
563 }"#;
564 let data: CtfEventData = serde_json::from_str(json).unwrap();
565 assert_eq!(data.id, 1434);
566 assert_eq!(data.challenges.len(), 1);
567 assert_eq!(data.challenges[0].name, "Test Challenge");
568 assert!(data.challenges[0].solved);
569 assert_eq!(data.challenges[0].flags_info.len(), 1);
570 let team = data.participating_team.unwrap();
571 assert_eq!(team.rank, Some(42));
572 assert_eq!(team.points, Some(5000));
573 }
574
575 #[test]
576 fn deserialize_ctf_scoreboard() {
577 let json = r#"{
578 "ctf_name": "CTF Try Out",
579 "ctf_teams": 300,
580 "ctf_players": 1200,
581 "is_ended": false,
582 "participating_team": {
583 "id": 321013,
584 "name": "v1olet",
585 "position": 42,
586 "points": 5000,
587 "solved_challenges": 10,
588 "total_challenges": 50,
589 "owned_flags": 10,
590 "first_bloods": 2
591 },
592 "scores": [{
593 "id": 1,
594 "name": "Top Team",
595 "country_code": "US",
596 "points": 10000,
597 "owned_flags": 50,
598 "first_bloods": 5
599 }]
600 }"#;
601 let sb: CtfScoreboard = serde_json::from_str(json).unwrap();
602 assert_eq!(sb.ctf_teams, Some(300));
603 assert_eq!(sb.scores.len(), 1);
604 assert_eq!(sb.scores[0].points, Some(10000));
605 let team = sb.participating_team.unwrap();
606 assert_eq!(team.position, Some(42));
607 }
608
609 #[test]
610 fn deserialize_ctf_solve() {
611 let json = r#"{
612 "challenge_name": "LootStash",
613 "challenge_category": "Reversing",
614 "team_name": "v1olet",
615 "user_name": "LANGSOMT",
616 "created_at": "2026-07-22T11:12:05.000000Z"
617 }"#;
618 let solve: CtfSolve = serde_json::from_str(json).unwrap();
619 assert_eq!(solve.challenge_name.as_deref(), Some("LootStash"));
620 assert_eq!(solve.team_name.as_deref(), Some("v1olet"));
621 }
622
623 #[test]
624 fn deserialize_ctf_flag_result() {
625 let json = r#"{"message": "Correct flag!", "points": 725}"#;
626 let result: CtfFlagResult = serde_json::from_str(json).unwrap();
627 assert_eq!(result.message, "Correct flag!");
628 assert_eq!(result.points, Some(725));
629 }
630
631 #[test]
632 fn deserialize_ctf_user_profile() {
633 let json = r#"{
634 "id": 1084837,
635 "name": "LANGSOMT",
636 "full_name": "Allan Kimmer Jensen",
637 "email": "test@example.com",
638 "timezone": "Europe/London",
639 "hasAnyTeam": true,
640 "hasNormalTeam": true,
641 "avatar": null,
642 "device_id": "abc"
643 }"#;
644 let profile: CtfUserProfile = serde_json::from_str(json).unwrap();
645 assert_eq!(profile.id, 1084837);
646 assert_eq!(profile.name, "LANGSOMT");
647 assert!(profile.has_any_team);
648 }
649}