Skip to content

Commit 8db8cb3

Browse files
committed
Added next_id support
1 parent d78a030 commit 8db8cb3

File tree

2 files changed

+82
-1
lines changed

2 files changed

+82
-1
lines changed

ctfcli/core/challenge.py

+75-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class Challenge(dict):
4747
"type", "extra", "image", "protocol", "host",
4848
"connection_info", "healthcheck", "attempts", "flags",
4949
"files", "topics", "tags", "files", "hints",
50-
"requirements", "state", "version",
50+
"requirements", "next_id", "state", "version",
5151
# fmt: on
5252
]
5353

@@ -103,6 +103,8 @@ def is_default_challenge_property(key: str, value: Any) -> bool:
103103
if key in ["tags", "hints", "topics", "requirements", "files"] and value == []:
104104
return True
105105

106+
if key == "next_id" and value is None:
107+
return True
106108
return False
107109

108110
@staticmethod
@@ -434,6 +436,40 @@ def _set_required_challenges(self):
434436
r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=requirements_payload)
435437
r.raise_for_status()
436438

439+
def _set_next_id(self, nid):
440+
if type(nid) == str:
441+
# nid by name
442+
# find the challenge id from installed challenges
443+
remote_challenges = self.load_installed_challenges()
444+
for remote_challenge in remote_challenges:
445+
if remote_challenge["name"] == nid:
446+
nid = remote_challenge["id"]
447+
break
448+
if type(nid) == str:
449+
click.secho(
450+
"Challenge cannot find next_id. Maybe it is invalid name or id. It will be cleared.",
451+
fg="yellow",
452+
)
453+
nid = None
454+
elif type(nid) == int and nid > 0:
455+
# nid by challenge id
456+
# trust it and use it directly
457+
nid = remote_challenge["id"]
458+
else:
459+
nid = None
460+
461+
if self.challenge_id == nid:
462+
click.secho(
463+
"Challenge cannot set next_id itself. Skipping invalid next_id.",
464+
fg="yellow",
465+
)
466+
nid = None
467+
468+
#return nid
469+
next_id_payload = {"next_id": nid}
470+
r = self.api.patch(f"/api/v1/challenges/{self.challenge_id}", json=next_id_payload)
471+
r.raise_for_status()
472+
437473
# Compare challenge requirements, will resolve all IDs to names
438474
def _compare_challenge_requirements(self, r1: List[Union[str, int]], r2: List[Union[str, int]]) -> bool:
439475
remote_challenges = self.load_installed_challenges()
@@ -453,6 +489,21 @@ def normalize_requirements(requirements):
453489

454490
return normalize_requirements(r1) == normalize_requirements(r2)
455491

492+
# Compare challenge next_id, will resolve all IDs to names
493+
def _compare_challenge_next_id(self, r1: Union[str, int, None], r2: Union[str, int, None]) -> bool:
494+
def normalize_next_id(r):
495+
normalized = None
496+
if type(r) == int:
497+
remote_challenge = self.load_installed_challenge(r)
498+
if remote_challenge["id"] == r:
499+
normalized = remote_challenge["name"]
500+
else:
501+
normalized = r
502+
503+
return normalized
504+
505+
return normalize_next_id(r1) == normalize_next_id(r2)
506+
456507
# Normalize challenge data from the API response to match challenge.yml
457508
# It will remove any extra fields from the remote, as well as expand external references
458509
# that have to be fetched separately (e.g., files, flags, hints, etc.)
@@ -521,6 +572,16 @@ def _normalize_challenge(self, challenge_data: Dict[str, Any]):
521572
challenges = r.json()["data"]
522573
challenge["requirements"] = [c["name"] for c in challenges if c["id"] in requirements]
523574

575+
# Add next_id
576+
nid = challenge_data.get("next_id", None)
577+
if nid:
578+
# Prefer challenge names over IDs
579+
r = self.api.get(f"/api/v1/challenges/{nid}")
580+
r.raise_for_status()
581+
challenge["next_id"] = r.json()["data"]["name"]
582+
else:
583+
challenge["next_id"] = None
584+
524585
return challenge
525586

526587
# Create a dictionary of remote files in { basename: {"url": "", "location": ""} } format
@@ -634,6 +695,11 @@ def sync(self, ignore: Tuple[str] = ()) -> None:
634695
if challenge.get("requirements") and "requirements" not in ignore:
635696
self._set_required_challenges()
636697

698+
# Set next_id
699+
nid = challenge.get("next_id", None)
700+
if "next_id" not in ignore:
701+
self._set_next_id(nid)
702+
637703
make_challenge_visible = False
638704

639705
# Bring back the challenge to be visible if:
@@ -711,6 +777,11 @@ def create(self, ignore: Tuple[str] = ()) -> None:
711777
if challenge.get("requirements") and "requirements" not in ignore:
712778
self._set_required_challenges()
713779

780+
# Add next_id
781+
nid = challenge.get("next_id", None)
782+
if "next_id" not in ignore:
783+
self._set_next_id(nid)
784+
714785
# Bring back the challenge if it's supposed to be visible
715786
# Either explicitly, or by assuming the default value (possibly because the state is ignored)
716787
if challenge.get("state", "visible") == "visible" or "state" in ignore:
@@ -864,6 +935,9 @@ def verify(self, ignore: Tuple[str] = ()) -> bool:
864935
if key == "requirements":
865936
if self._compare_challenge_requirements(challenge[key], normalized_challenge[key]):
866937
continue
938+
if key == "next_id":
939+
if self._compare_challenge_next_id(challenge[key], normalized_challenge[key]):
940+
continue
867941

868942
return False
869943

ctfcli/spec/challenge-example.yml

+7
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ requirements:
115115
- "Warmup"
116116
- "Are you alive"
117117

118+
# The next_id is used to display a next recommended challenge to a user when
119+
# the user correctly answers the current challenge.
120+
# Can be removed if unused
121+
# Accepts a challenge name as a string, a challenge ID as an integer, or null
122+
# if you want to remove or disable it.
123+
next_id: null
124+
118125
# The state of the challenge.
119126
# If the field is omitted, the challenge is visible by default.
120127
# If provided, the field can take one of two values: hidden, visible.

0 commit comments

Comments
 (0)