From 2ea8358fe9fc8070ef5d0457321d2fa5c8bfddbf Mon Sep 17 00:00:00 2001 From: Kevin Chung Date: Tue, 20 Aug 2024 14:16:35 -0400 Subject: [PATCH] Add Challenge.clone staticmethod to clone challenges from remote --- ctfcli/cli/challenges.py | 22 +++++---- ctfcli/core/challenge.py | 45 ++++++++++++++++++- .../templates/blank/empty/cookiecutter.json | 4 ++ .../{{cookiecutter.dirname}}/challenge.yml | 1 + 4 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 ctfcli/templates/blank/empty/cookiecutter.json create mode 100644 ctfcli/templates/blank/empty/{{cookiecutter.dirname}}/challenge.yml diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index 551969b..fc34210 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -928,11 +928,13 @@ def mirror( files_directory: str = "dist", skip_verify: bool = False, ignore: Union[str, Tuple[str]] = (), + create: bool = False, ) -> int: log.debug( f"mirror: (challenge={challenge}, files_directory={files_directory}, " f"skip_verify={skip_verify}, ignore={ignore})" ) + config = Config() if challenge: challenge_instance = self._resolve_single_challenge(challenge) @@ -947,18 +949,22 @@ def mirror( ignore = (ignore,) remote_challenges = Challenge.load_installed_challenges() - if len(local_challenges) > 1: - # Issue a warning if there are extra challenges on the remote that do not have a local version - local_challenge_names = [c["name"] for c in local_challenges] - for remote_challenge in remote_challenges: - if remote_challenge["name"] not in local_challenge_names: + # Issue a warning if there are extra challenges on the remote that do not have a local version + local_challenge_names = [c["name"] for c in local_challenges] + for remote_challenge in remote_challenges: + if remote_challenge["name"] not in local_challenge_names: + click.secho( + f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config", + fg="yellow", + ) + if create: click.secho( - f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config\n" - "Mirroring does not create new local challenges\n" - "Please add the local challenge if you wish to manage it with ctfcli\n", + f"Mirroring '{remote_challenge['name']}' to local due to --create", fg="yellow", ) + challenge_instance = Challenge.clone(config=config, remote_challenge=remote_challenge) + challenge_instance.mirror(files_directory_name=files_directory, ignore=ignore) failed_mirrors = [] with click.progressbar(local_challenges, label="Mirroring challenges") as challenges: diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py index 333a2f2..100fc8c 100644 --- a/ctfcli/core/challenge.py +++ b/ctfcli/core/challenge.py @@ -1,3 +1,4 @@ +import logging import re import subprocess from os import PathLike @@ -6,10 +7,12 @@ import click import yaml +from cookiecutter.main import cookiecutter from slugify import slugify from ctfcli.core.api import API from ctfcli.core.exceptions import ( + ChallengeException, InvalidChallengeDefinition, InvalidChallengeFile, LintException, @@ -19,6 +22,8 @@ from ctfcli.utils.hashing import hash_file from ctfcli.utils.tools import strings +log = logging.getLogger("ctfcli.core.challenge") + def str_presenter(dumper, data): if len(data.splitlines()) > 1 or "\n" in data: @@ -100,6 +105,43 @@ def is_default_challenge_property(key: str, value: Any) -> bool: return False + @staticmethod + def clone(config, remote_challenge): + name = remote_challenge["name"] + + if name is None: + raise ChallengeException(f'Could not get name of remote challenge with id {remote_challenge["id"]}') + + # First, generate a name for the challenge directory + category = remote_challenge.get("category", None) + challenge_dir_name = slugify(name) + if category is not None: + challenge_dir_name = str(Path(slugify(category)) / challenge_dir_name) + + if Path(challenge_dir_name).exists(): + raise ChallengeException( + f"Challenge directory '{challenge_dir_name}' for challenge '{name}' already exists" + ) + + # Create an blank/empty challenge, with only the challenge.yml containing the challenge name + template_path = config.get_base_path() / "templates" / "blank" / "empty" + log.debug(f"Challenge.clone: cookiecutter({str(template_path)}, {name=}, {challenge_dir_name=}") + cookiecutter( + str(template_path), + no_input=True, + extra_context={"name": name, "dirname": challenge_dir_name}, + ) + + if not Path(challenge_dir_name).exists(): + raise ChallengeException(f"Could not create challenge directory '{challenge_dir_name}' for '{name}'") + + # Add the newly created local challenge to the config file + config["challenges"][challenge_dir_name] = challenge_dir_name + with open(config.config_path, "w+") as f: + config.write(f) + + return Challenge(f"{challenge_dir_name}/challenge.yml") + @property def api(self): if not self._api: @@ -110,6 +152,7 @@ def api(self): # __init__ expects an absolute path to challenge_yml, or a relative one from the cwd # it does not join that path with the project_path def __init__(self, challenge_yml: Union[str, PathLike], overrides=None): + log.debug(f"Challenge.__init__: ({challenge_yml=}, {overrides=}") if overrides is None: overrides = {} @@ -209,7 +252,7 @@ def _load_challenge_id(self): def _validate_files(self): # if the challenge defines files, make sure they exist before making any changes to the challenge - for challenge_file in self["files"]: + for challenge_file in self.get("files", []): if not (self.challenge_directory / challenge_file).exists(): raise InvalidChallengeFile(f"File {challenge_file} could not be loaded") diff --git a/ctfcli/templates/blank/empty/cookiecutter.json b/ctfcli/templates/blank/empty/cookiecutter.json new file mode 100644 index 0000000..21a8468 --- /dev/null +++ b/ctfcli/templates/blank/empty/cookiecutter.json @@ -0,0 +1,4 @@ +{ + "name": "challenge", + "dirname": "challenge" +} \ No newline at end of file diff --git a/ctfcli/templates/blank/empty/{{cookiecutter.dirname}}/challenge.yml b/ctfcli/templates/blank/empty/{{cookiecutter.dirname}}/challenge.yml new file mode 100644 index 0000000..2e841df --- /dev/null +++ b/ctfcli/templates/blank/empty/{{cookiecutter.dirname}}/challenge.yml @@ -0,0 +1 @@ +name: "{{cookiecutter.name}}" \ No newline at end of file