From 0c81d6e82bebf13f7056d2e7782abc25db778685 Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Tue, 10 Feb 2026 18:06:36 -0500 Subject: [PATCH] Add support for solutions based on writeups --- ctfcli/core/challenge.py | 110 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py index 96cd87f..dc4c58f 100644 --- a/ctfcli/core/challenge.py +++ b/ctfcli/core/challenge.py @@ -442,6 +442,112 @@ def _create_hints(self): r = self.api.post("/api/v1/hints", json=hint_payload) r.raise_for_status() + + def _resolve_writeup(self) -> Optional[Path]: + writeup_path_candidates = [ + self.challenge_directory / "writeup" / "WRITEUP.md", + self.challenge_directory / "writeup" / "writeup.md", + self.challenge_directory / "WRITEUP.md", + self.challenge_directory / "writeup.md", + self.challenge_directory / "writeup" / "README.md", + self.challenge_directory / "writeup" / "readme.md", + ] + + writeup_path = None + for candidate in writeup_path_candidates: + if candidate.exists(): + writeup_path = candidate + break + + if writeup_path is None: + click.secho( + f"Could not find a writeup file for challenge {self}!", + fg="red", + ) + return + + return writeup_path + + def _delete_existing_solution(self): + remote_solutions = self.api.get("/api/v1/solutions").json()["data"] + for solution in remote_solutions: + if solution["challenge_id"] == self.challenge_id: + r = self.api.delete(f"/api/v1/solutions/{solution['id']}") + r.raise_for_status() + + def _create_solution(self): + writeup_path = self._resolve_writeup() + + if not writeup_path: + click.secho( + f"Failed to create solution for {self}!", + fg="red", + ) + return + + solution_payload_create = { + "challenge_id": self.challenge_id, + "state": "hidden", + "content": "" + } + + r = self.api.post("/api/v1/solutions", json=solution_payload_create) + r.raise_for_status() + solution_id = r.json()["data"]["id"] + + with writeup_path.open("r") as writeup_file: + content = writeup_file.read() + + # Find all images in the content (both markdown and HTML formats) + # Markdown format: ![alt text](image_url) + # Returns tuples: (full_match, alt_text, image_path) + markdown_images = re.findall(r'(!\[([^\]]*)\]\(([^\)]+)\))', content) + # HTML format: + # Returns tuples: (full_match, image_path) + html_images = re.findall(r'(]+src=["\']([^"\']+)["\'][^>]*>)', content) + + # Find all snippet includes (MkDocs style: --8<-- "filename") + # Returns tuples: (full_match, filename) + snippet_includes = re.findall(r'(--8<--\s+["\']([^"\']+)["\'])', content) + + + for mdx, alt, path in markdown_images: + new_file = ("file", open(writeup_path.parent / path, mode="rb")) + file_payload = { + "type": "solution", + "solution_id": solution_id, + } + + # Specifically use data= here to send multipart/form-data + r = self.api.post("/api/v1/files", files=[new_file], data=file_payload) + r.raise_for_status() + resp = r.json() + server_location = resp["data"][0]["location"] + content = content.replace(mdx, f"![{alt}](/files/{server_location})") + + # Process snippet includes (--8<-- "filename") + for full_match, filename in snippet_includes: + snippet_file_path = writeup_path.parent / filename + if snippet_file_path.exists(): + with snippet_file_path.open("r") as snippet_file: + snippet_content = snippet_file.read() + # Replace the --8<-- directive with the actual file content + content = content.replace(full_match, snippet_content) + else: + log.warning(f"Snippet file not found: {filename}") + + # # Log found images for debugging + # if markdown_images: + # print(f"Found {len(markdown_images)} markdown images in writeup") + # if html_images: + # log.debug(f"Found {len(html_images)} HTML images in writeup") + + solution_payload_patch = { + "content": content + } + r = self.api.patch(f"/api/v1/solutions/{solution_id}", json=solution_payload_patch) + r.raise_for_status() + def _set_required_challenges(self): remote_challenges = self.load_installed_challenges() required_challenges = [] @@ -796,6 +902,10 @@ def sync(self, ignore: tuple[str] = ()) -> None: if "next" not in ignore: self._set_next(_next) + if "solution" not in ignore: + # self._delete_existing_solution() + self._create_solution() + make_challenge_visible = False # Bring back the challenge to be visible if: