diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py index f1754eb..1a27837 100644 --- a/ctfcli/core/challenge.py +++ b/ctfcli/core/challenge.py @@ -17,9 +17,11 @@ InvalidChallengeDefinition, InvalidChallengeFile, LintException, + ProjectNotInitialized, RemoteChallengeNotFound, ) from ctfcli.core.image import Image +from ctfcli.core.media import Media from ctfcli.utils.hashing import hash_file from ctfcli.utils.tools import strings @@ -365,12 +367,36 @@ def _validate_files(self): if not (self.challenge_directory / challenge_file).exists(): raise InvalidChallengeFile(f"File {challenge_file} could not be loaded") + def _render_media(self, text): + # Substitute [media] placeholders from .ctf/config; no-op if text isn't a + # string or the project config can't be located (e.g. outside a project). + if not isinstance(text, str): + return text + try: + return Media.replace_placeholders(text) + except ProjectNotInitialized: + return text + + def _render_media_in_hints(self, hints): + # Return a copy of the hints list with [media] placeholders rendered in + # each hint's content, preserving the str-vs-dict structure used by + # challenge.yml / _normalize_challenge. + rendered = [] + for h in hints: + if isinstance(h, str): + rendered.append(self._render_media(h)) + elif isinstance(h, dict) and "content" in h: + rendered.append({**h, "content": self._render_media(h["content"])}) + else: + rendered.append(h) + return rendered + def _get_initial_challenge_payload(self, ignore: tuple[str] = ()) -> dict: challenge = self challenge_payload = { "name": self["name"], "category": self.get("category", ""), - "description": self.get("description", ""), + "description": self._render_media(self.get("description", "") or ""), "attribution": self.get("attribution", ""), "type": self.get("type", "standard"), # Hide the challenge for the duration of the sync / creation @@ -511,7 +537,7 @@ def _create_hints(self): for idx, hint in enumerate(self["hints"]): if type(hint) == str: hint_payload = { - "content": hint, + "content": self._render_media(hint), "title": "", "cost": 0, "challenge_id": self.challenge_id, @@ -520,7 +546,7 @@ def _create_hints(self): else: has_requirements = bool(hint.get("requirements")) hint_payload = { - "content": "" if has_requirements else hint["content"], + "content": "" if has_requirements else self._render_media(hint["content"]), "title": hint.get("title", ""), "cost": hint.get("cost", 0), "challenge_id": self.challenge_id, @@ -567,7 +593,7 @@ def _create_hints(self): # Now safe to set the real content r = self.api.patch( f"/api/v1/hints/{hint_id}", - json={"content": hint["content"]}, + json={"content": self._render_media(hint["content"])}, ) r.raise_for_status() @@ -1457,6 +1483,15 @@ def verify(self, ignore: tuple[str] = ()) -> bool: if key == "module" and self._compare_challenge_module(challenge[key], normalized_challenge[key]): continue + # Render [media] placeholders locally before comparing, so a + # challenge pushed with placeholders still verifies against the + # substituted values stored on the remote. + if key == "description" and self._render_media(challenge[key]) == normalized_challenge[key]: + continue + + if key == "hints" and self._render_media_in_hints(challenge[key]) == normalized_challenge[key]: + continue + click.secho( f"{key} comparison failed.", fg="yellow", diff --git a/tests/core/test_challenge.py b/tests/core/test_challenge.py index 5554a4d..c083bb6 100644 --- a/tests/core/test_challenge.py +++ b/tests/core/test_challenge.py @@ -2510,6 +2510,158 @@ def test_mirror_challenge(self, mock_api_constructor: MagicMock): loaded_data = yaml.safe_load(dumped_data) self.assertDictEqual(expected_challenge, loaded_data) + @mock.patch( + "ctfcli.core.config.Path.cwd", + return_value=BASE_DIR / "fixtures" / "challenges" / "test-challenge-full", + ) + @mock.patch("ctfcli.core.challenge.API") + def test_verify_renders_media_placeholders(self, mock_api_constructor: MagicMock, *args, **kwargs): + # The remote stores the substituted values, so verify must render the + # local [media] placeholders before comparing to avoid a false mismatch. + def media_get(*get_args, **get_kwargs): + response = self.mock_get(*get_args, **get_kwargs) + path = get_args[0] + + if path in ("/api/v1/challenges/3", "/api/v1/challenges/3?view=admin"): + response.json.return_value["data"]["description"] = "/files/media/logo.png" + + if path == "/api/v1/challenges/3/hints": + response.json.return_value["data"][0]["content"] = "/files/media/handout.zip" + + return response + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.side_effect = media_get + + challenge = Challenge( + self.full_challenge, + { + "description": "{logo}", + "hints": ["{handout}", {"content": "paid hint", "cost": 100}], + }, + ) + challenge.challenge_id = 3 + + self.assertTrue(challenge.verify(ignore=["files"])) + + +class TestMediaPlaceholders(unittest.TestCase): + minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml" + minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" + + installed_challenges = [{"id": 1, "name": "Test Challenge"}] + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.challenge.API") + def test_create_substitutes_media_in_description(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"description": "See {logo}", "state": "hidden"}) + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.post.return_value.json.return_value = {"success": True, "data": {"id": 1}} + + challenge.create() + + create_call = mock_api.post.call_args_list[0] + self.assertEqual(create_call.args[0], "/api/v1/challenges") + self.assertEqual(create_call.kwargs["json"]["description"], "See /files/media/logo.png") + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch( + "ctfcli.core.challenge.Challenge.load_installed_challenge", + return_value={"id": 1, "name": "Test Challenge", "state": "hidden", "files": []}, + ) + @mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges) + @mock.patch("ctfcli.core.challenge.API") + def test_sync_substitutes_media_in_description(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge(self.minimal_challenge, {"description": "See {logo}", "state": "hidden"}) + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.get.return_value.json.return_value = {"success": True, "data": []} + + challenge.sync(ignore=["files"]) + + mock_api.patch.assert_any_call( + "/api/v1/challenges/1", + json={ + "name": "Test Challenge", + "category": "New Test", + "description": "See /files/media/logo.png", + "attribution": "New Test Attribution", + "type": "standard", + "value": 150, + "state": "hidden", + "max_attempts": 0, + "connection_info": None, + "scheduled_at": None, + }, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd) + @mock.patch("ctfcli.core.challenge.API") + def test_create_hints_substitutes_media(self, mock_api_constructor: MagicMock, *args, **kwargs): + challenge = Challenge( + self.minimal_challenge, + { + "hints": [ + "download {handout}", + {"content": "logo {logo}", "cost": 50}, + {"key": "h1", "content": "base {logo}"}, + {"key": "h2", "content": "deeper {handout}", "requirements": ["h1"]}, + ] + }, + ) + challenge.challenge_id = 1 + + mock_api: MagicMock = mock_api_constructor.return_value + post_responses = [] + for hint_id in range(10, 14): + response = MagicMock() + response.json.return_value = {"success": True, "data": {"id": hint_id}} + post_responses.append(response) + mock_api.post.side_effect = post_responses + + challenge._create_hints() + + # plain-string hint content is substituted + mock_api.post.assert_any_call( + "/api/v1/hints", + json={ + "content": "download /files/media/handout.zip", + "title": "", + "cost": 0, + "challenge_id": 1, + }, + ) + # dict hint content is substituted + mock_api.post.assert_any_call( + "/api/v1/hints", + json={"content": "logo /files/media/logo.png", "title": "", "cost": 50, "challenge_id": 1}, + ) + # requirement-gated hint is posted blank, then its real (substituted) content is patched in + mock_api.post.assert_any_call( + "/api/v1/hints", + json={"content": "", "title": "", "cost": 0, "challenge_id": 1}, + ) + mock_api.patch.assert_any_call( + "/api/v1/hints/13", + json={"content": "deeper /files/media/handout.zip"}, + ) + + @mock.patch("ctfcli.core.config.Path.cwd", return_value=Path("/")) + @mock.patch("ctfcli.core.challenge.API") + def test_no_substitution_without_project(self, mock_api_constructor: MagicMock, *args, **kwargs): + # Outside of a project (no .ctf/config), substitution is a defensive no-op + # and the raw token is sent unchanged. + challenge = Challenge(self.minimal_challenge, {"description": "See {logo}", "state": "hidden"}) + + mock_api: MagicMock = mock_api_constructor.return_value + mock_api.post.return_value.json.return_value = {"success": True, "data": {"id": 1}} + + challenge.create() + + create_call = mock_api.post.call_args_list[0] + self.assertEqual(create_call.kwargs["json"]["description"], "See {logo}") + class TestSaveChallenge(unittest.TestCase): full_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-full" / "challenge.yml" diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 1082d08..752d571 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -69,6 +69,10 @@ def test_returns_correct_json_representation(self, *args, **kwargs): "test-challenge-full": "user@host:example/test-challenge-full.git", "test-challenge-minimal": "user@host:example/test-challenge-minimal.git", }, + "media": { + "logo": "/files/media/logo.png", + "handout": "/files/media/handout.zip", + }, } config_data = json.loads(config.as_json()) diff --git a/tests/fixtures/challenges/.ctf/config b/tests/fixtures/challenges/.ctf/config index 923aedd..9375305 100644 --- a/tests/fixtures/challenges/.ctf/config +++ b/tests/fixtures/challenges/.ctf/config @@ -8,3 +8,7 @@ test-challenge-full = user@host:example/test-challenge-full.git test-challenge-files = user@host:example/test-challenge-files.git test-challenge-dockerfile = user@host:example/test-challenge-dockerfile.git +[media] +logo = /files/media/logo.png +handout = /files/media/handout.zip +