Skip to content

Commit 7a6067d

Browse files
authored
Add Challenge.clone staticmethod to clone challenges from remote (#153)
1 parent 40f72fa commit 7a6067d

File tree

4 files changed

+63
-9
lines changed

4 files changed

+63
-9
lines changed

ctfcli/cli/challenges.py

+14-8
Original file line numberDiff line numberDiff line change
@@ -928,11 +928,13 @@ def mirror(
928928
files_directory: str = "dist",
929929
skip_verify: bool = False,
930930
ignore: Union[str, Tuple[str]] = (),
931+
create: bool = False,
931932
) -> int:
932933
log.debug(
933934
f"mirror: (challenge={challenge}, files_directory={files_directory}, "
934935
f"skip_verify={skip_verify}, ignore={ignore})"
935936
)
937+
config = Config()
936938

937939
if challenge:
938940
challenge_instance = self._resolve_single_challenge(challenge)
@@ -947,18 +949,22 @@ def mirror(
947949
ignore = (ignore,)
948950

949951
remote_challenges = Challenge.load_installed_challenges()
950-
if len(local_challenges) > 1:
951-
# Issue a warning if there are extra challenges on the remote that do not have a local version
952-
local_challenge_names = [c["name"] for c in local_challenges]
953952

954-
for remote_challenge in remote_challenges:
955-
if remote_challenge["name"] not in local_challenge_names:
953+
# Issue a warning if there are extra challenges on the remote that do not have a local version
954+
local_challenge_names = [c["name"] for c in local_challenges]
955+
for remote_challenge in remote_challenges:
956+
if remote_challenge["name"] not in local_challenge_names:
957+
click.secho(
958+
f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config",
959+
fg="yellow",
960+
)
961+
if create:
956962
click.secho(
957-
f"Found challenge '{remote_challenge['name']}' in CTFd, but not in .ctf/config\n"
958-
"Mirroring does not create new local challenges\n"
959-
"Please add the local challenge if you wish to manage it with ctfcli\n",
963+
f"Mirroring '{remote_challenge['name']}' to local due to --create",
960964
fg="yellow",
961965
)
966+
challenge_instance = Challenge.clone(config=config, remote_challenge=remote_challenge)
967+
challenge_instance.mirror(files_directory_name=files_directory, ignore=ignore)
962968

963969
failed_mirrors = []
964970
with click.progressbar(local_challenges, label="Mirroring challenges") as challenges:

ctfcli/core/challenge.py

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import re
23
import subprocess
34
from os import PathLike
@@ -6,10 +7,12 @@
67

78
import click
89
import yaml
10+
from cookiecutter.main import cookiecutter
911
from slugify import slugify
1012

1113
from ctfcli.core.api import API
1214
from ctfcli.core.exceptions import (
15+
ChallengeException,
1316
InvalidChallengeDefinition,
1417
InvalidChallengeFile,
1518
LintException,
@@ -19,6 +22,8 @@
1922
from ctfcli.utils.hashing import hash_file
2023
from ctfcli.utils.tools import strings
2124

25+
log = logging.getLogger("ctfcli.core.challenge")
26+
2227

2328
def str_presenter(dumper, data):
2429
if len(data.splitlines()) > 1 or "\n" in data:
@@ -100,6 +105,43 @@ def is_default_challenge_property(key: str, value: Any) -> bool:
100105

101106
return False
102107

108+
@staticmethod
109+
def clone(config, remote_challenge):
110+
name = remote_challenge["name"]
111+
112+
if name is None:
113+
raise ChallengeException(f'Could not get name of remote challenge with id {remote_challenge["id"]}')
114+
115+
# First, generate a name for the challenge directory
116+
category = remote_challenge.get("category", None)
117+
challenge_dir_name = slugify(name)
118+
if category is not None:
119+
challenge_dir_name = str(Path(slugify(category)) / challenge_dir_name)
120+
121+
if Path(challenge_dir_name).exists():
122+
raise ChallengeException(
123+
f"Challenge directory '{challenge_dir_name}' for challenge '{name}' already exists"
124+
)
125+
126+
# Create an blank/empty challenge, with only the challenge.yml containing the challenge name
127+
template_path = config.get_base_path() / "templates" / "blank" / "empty"
128+
log.debug(f"Challenge.clone: cookiecutter({str(template_path)}, {name=}, {challenge_dir_name=}")
129+
cookiecutter(
130+
str(template_path),
131+
no_input=True,
132+
extra_context={"name": name, "dirname": challenge_dir_name},
133+
)
134+
135+
if not Path(challenge_dir_name).exists():
136+
raise ChallengeException(f"Could not create challenge directory '{challenge_dir_name}' for '{name}'")
137+
138+
# Add the newly created local challenge to the config file
139+
config["challenges"][challenge_dir_name] = challenge_dir_name
140+
with open(config.config_path, "w+") as f:
141+
config.write(f)
142+
143+
return Challenge(f"{challenge_dir_name}/challenge.yml")
144+
103145
@property
104146
def api(self):
105147
if not self._api:
@@ -110,6 +152,7 @@ def api(self):
110152
# __init__ expects an absolute path to challenge_yml, or a relative one from the cwd
111153
# it does not join that path with the project_path
112154
def __init__(self, challenge_yml: Union[str, PathLike], overrides=None):
155+
log.debug(f"Challenge.__init__: ({challenge_yml=}, {overrides=}")
113156
if overrides is None:
114157
overrides = {}
115158

@@ -209,7 +252,7 @@ def _load_challenge_id(self):
209252

210253
def _validate_files(self):
211254
# if the challenge defines files, make sure they exist before making any changes to the challenge
212-
for challenge_file in self["files"]:
255+
for challenge_file in self.get("files", []):
213256
if not (self.challenge_directory / challenge_file).exists():
214257
raise InvalidChallengeFile(f"File {challenge_file} could not be loaded")
215258

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "challenge",
3+
"dirname": "challenge"
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
name: "{{cookiecutter.name}}"

0 commit comments

Comments
 (0)