@@ -56,6 +56,7 @@ class Challenge(dict):
5656 "host" ,
5757 "connection_info" ,
5858 "healthcheck" ,
59+ "solution" ,
5960 "attempts" ,
6061 "logic" ,
6162 "flags" ,
@@ -442,6 +443,127 @@ def _create_hints(self):
442443 r = self .api .post ("/api/v1/hints" , json = hint_payload )
443444 r .raise_for_status ()
444445
446+ def _parse_solution_definition (self ) -> tuple [str , str ] | None :
447+ solution = self .get ("solution" , None )
448+ if not solution :
449+ return None
450+
451+ if type (solution ) == str :
452+ return solution , "hidden"
453+
454+ if type (solution ) != dict :
455+ click .secho (
456+ "The solution field must be a string path or an object with path and state" ,
457+ fg = "red" ,
458+ )
459+ return None
460+
461+ solution_path = solution .get ("path" )
462+ if type (solution_path ) != str or not solution_path :
463+ click .secho ("The solution object must define a non-empty string path field" , fg = "red" )
464+ return None
465+
466+ solution_state = solution .get ("state" , "hidden" )
467+ if type (solution_state ) != str or solution_state not in ["hidden" , "visible" , "solved" ]:
468+ click .secho ("The solution state must be one of: hidden, visible, solved" , fg = "red" )
469+ return None
470+
471+ return solution_path , solution_state
472+
473+ def _resolve_solution_path (self ) -> tuple [Path , str ] | None :
474+ parsed_solution = self ._parse_solution_definition ()
475+ if not parsed_solution :
476+ return None
477+
478+ solution_path_string , solution_state = parsed_solution
479+ solution_path = self .challenge_directory / solution_path_string
480+ if not solution_path .is_file ():
481+ click .secho (
482+ f"Solution file '{ solution_path_string } ' specified, but not found at { solution_path } " ,
483+ fg = "red" ,
484+ )
485+ return None
486+
487+ return solution_path , solution_state
488+
489+ def _delete_existing_solution (self ):
490+ remote_solutions = self .api .get ("/api/v1/solutions" ).json ()["data" ]
491+ for solution in remote_solutions :
492+ if solution ["challenge_id" ] == self .challenge_id :
493+ r = self .api .delete (f"/api/v1/solutions/{ solution ['id' ]} " )
494+ r .raise_for_status ()
495+
496+ def _get_existing_solution_id (self ) -> int | None :
497+ r = self .api .get ("/api/v1/solutions" )
498+ r .raise_for_status ()
499+ remote_solutions = r .json ().get ("data" ) or []
500+ for solution in remote_solutions :
501+ if solution ["challenge_id" ] == self .challenge_id :
502+ return solution ["id" ]
503+ return None
504+
505+ def _create_solution (self ):
506+ resolved_solution = self ._resolve_solution_path ()
507+ if not resolved_solution :
508+ return
509+ solution_path , solution_state = resolved_solution
510+
511+ solution_id = self ._get_existing_solution_id ()
512+ if solution_id is None :
513+ solution_payload_create = {"challenge_id" : self .challenge_id , "state" : solution_state , "content" : "" }
514+
515+ r = self .api .post ("/api/v1/solutions" , json = solution_payload_create )
516+ r .raise_for_status ()
517+ solution_id = r .json ()["data" ]["id" ]
518+ else :
519+ # Keep solution state in sync and clear stale content before rebuilding references.
520+ r = self .api .patch (
521+ f"/api/v1/solutions/{ solution_id } " ,
522+ json = {"state" : solution_state , "content" : "" },
523+ )
524+ r .raise_for_status ()
525+
526+ with solution_path .open ("r" ) as solution_file :
527+ content = solution_file .read ()
528+
529+ # Find all images in the content (markdown format; ignore html format)
530+ # Markdown format: 
531+ # Returns tuples: (full_match, alt_text, image_path)
532+ markdown_images = re .findall (r"(!\[([^\]]*)\]\(([^\)]+)\))" , content )
533+
534+ # Find all snippet includes (MkDocs style: --8<-- "filename")
535+ # Returns tuples: (full_match, filename)
536+ snippet_includes = re .findall (r'(--8<--\s+["\']([^"\']+)["\'])' , content )
537+
538+ for mdx , alt , path in markdown_images :
539+ new_file = ("file" , open (solution_path .parent / path , mode = "rb" ))
540+ file_payload = {
541+ "type" : "solution" ,
542+ "solution_id" : solution_id ,
543+ }
544+
545+ # Specifically use data= here to send multipart/form-data
546+ r = self .api .post ("/api/v1/files" , files = [new_file ], data = file_payload )
547+ r .raise_for_status ()
548+ resp = r .json ()
549+ server_location = resp ["data" ][0 ]["location" ]
550+ content = content .replace (mdx , f"" )
551+
552+ # Process snippet includes (--8<-- "filename")
553+ for full_match , filename in snippet_includes :
554+ snippet_file_path = solution_path .parent / filename
555+ if snippet_file_path .exists ():
556+ with snippet_file_path .open ("r" ) as snippet_file :
557+ snippet_content = snippet_file .read ()
558+ # Replace the --8<-- directive with the actual file content
559+ content = content .replace (full_match , snippet_content )
560+ else :
561+ log .warning (f"Snippet file not found: { filename } " )
562+
563+ solution_payload_patch = {"content" : content }
564+ r = self .api .patch (f"/api/v1/solutions/{ solution_id } " , json = solution_payload_patch )
565+ r .raise_for_status ()
566+
445567 def _set_required_challenges (self ):
446568 remote_challenges = self .load_installed_challenges ()
447569 required_challenges = []
@@ -796,6 +918,10 @@ def sync(self, ignore: tuple[str] = ()) -> None:
796918 if "next" not in ignore :
797919 self ._set_next (_next )
798920
921+ if "solution" not in ignore :
922+ # self._delete_existing_solution()
923+ self ._create_solution ()
924+
799925 make_challenge_visible = False
800926
801927 # Bring back the challenge to be visible if:
@@ -880,6 +1006,10 @@ def create(self, ignore: tuple[str] = ()) -> None:
8801006 if "next" not in ignore :
8811007 self ._set_next (_next )
8821008
1009+ # Add solution
1010+ if "solution" not in ignore :
1011+ self ._create_solution ()
1012+
8831013 # Bring back the challenge if it's supposed to be visible
8841014 # Either explicitly, or by assuming the default value (possibly because the state is ignored)
8851015 if challenge .get ("state" , "visible" ) == "visible" or "state" in ignore :
@@ -950,6 +1080,33 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool:
9501080 f"Challenge file '{ challenge_file } ' specified, but not found at { challenge_file_path } "
9511081 )
9521082
1083+ # Check that the optional solution file exists
1084+ solution = self .get ("solution" , None )
1085+ if solution :
1086+ solution_file = None
1087+ solution_state = "hidden"
1088+
1089+ if type (solution ) == str :
1090+ solution_file = solution
1091+ elif type (solution ) == dict :
1092+ solution_file = solution .get ("path" )
1093+ solution_state = solution .get ("state" , "hidden" )
1094+
1095+ if type (solution_state ) != str or solution_state not in ["hidden" , "visible" , "solved" ]:
1096+ issues ["fields" ].append ("The solution state must be one of: hidden, visible, solved" )
1097+
1098+ else :
1099+ issues ["fields" ].append ("The solution field must be a string path or an object with path and state" )
1100+
1101+ if type (solution_file ) != str or not solution_file :
1102+ issues ["fields" ].append ("The solution object must define a non-empty string path field" )
1103+ else :
1104+ solution_file_path = self .challenge_directory / solution_file
1105+ if solution_file_path .is_file () is False :
1106+ issues ["files" ].append (
1107+ f"Solution file '{ solution_file } ' specified, but not found at { solution_file_path } "
1108+ )
1109+
9531110 # Check that files don't have a flag in them
9541111 for challenge_file in files :
9551112 challenge_file_path = self .challenge_directory / challenge_file
0 commit comments