@@ -56,6 +56,7 @@ class Challenge(dict):
5656 "host" ,
5757 "connection_info" ,
5858 "healthcheck" ,
59+ "solution" ,
5960 "attempts" ,
6061 "logic" ,
6162 "flags" ,
@@ -442,77 +443,100 @@ 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
445450
446- def _resolve_writeup (self ) -> Optional [Path ]:
447- writeup_path_candidates = [
448- self .challenge_directory / "writeup" / "WRITEUP.md" ,
449- self .challenge_directory / "writeup" / "writeup.md" ,
450- self .challenge_directory / "WRITEUP.md" ,
451- self .challenge_directory / "writeup.md" ,
452- self .challenge_directory / "writeup" / "README.md" ,
453- self .challenge_directory / "writeup" / "readme.md" ,
454- ]
451+ if type (solution ) == str :
452+ return solution , "hidden"
455453
456- writeup_path = None
457- for candidate in writeup_path_candidates :
458- if candidate .exists ():
459- writeup_path = candidate
460- break
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
461472
462- if writeup_path is None :
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 ():
463481 click .secho (
464- f"Could not find a writeup file for challenge { self } ! " ,
482+ f"Solution file ' { solution_path_string } ' specified, but not found at { solution_path } " ,
465483 fg = "red" ,
466484 )
467- return
485+ return None
486+
487+ return solution_path , solution_state
468488
469- return writeup_path
470-
471489 def _delete_existing_solution (self ):
472490 remote_solutions = self .api .get ("/api/v1/solutions" ).json ()["data" ]
473491 for solution in remote_solutions :
474492 if solution ["challenge_id" ] == self .challenge_id :
475493 r = self .api .delete (f"/api/v1/solutions/{ solution ['id' ]} " )
476494 r .raise_for_status ()
477495
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+
478505 def _create_solution (self ):
479- writeup_path = self ._resolve_writeup ()
506+ resolved_solution = self ._resolve_solution_path ()
507+ if not resolved_solution :
508+ return
509+ solution_path , solution_state = resolved_solution
480510
481- if not writeup_path :
482- click .secho (
483- f"Failed to create solution for { self } !" ,
484- fg = "red" ,
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" : "" },
485523 )
486- return
487-
488- solution_payload_create = {
489- "challenge_id" : self .challenge_id ,
490- "state" : "hidden" ,
491- "content" : ""
492- }
524+ r .raise_for_status ()
493525
494- r = self .api .post ("/api/v1/solutions" , json = solution_payload_create )
495- r .raise_for_status ()
496- solution_id = r .json ()["data" ]["id" ]
497-
498- with writeup_path .open ("r" ) as writeup_file :
499- content = writeup_file .read ()
500-
501- # Find all images in the content (both markdown and HTML formats)
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)
502530 # Markdown format: 
503531 # Returns tuples: (full_match, alt_text, image_path)
504- markdown_images = re .findall (r'(!\[([^\]]*)\]\(([^\)]+)\))' , content )
505- # HTML format: <img src="..." />
506- # Returns tuples: (full_match, image_path)
507- html_images = re .findall (r'(<img[^>]+src=["\']([^"\']+)["\'][^>]*>)' , content )
508-
532+ markdown_images = re .findall (r"(!\[([^\]]*)\]\(([^\)]+)\))" , content )
533+
509534 # Find all snippet includes (MkDocs style: --8<-- "filename")
510535 # Returns tuples: (full_match, filename)
511536 snippet_includes = re .findall (r'(--8<--\s+["\']([^"\']+)["\'])' , content )
512537
513-
514538 for mdx , alt , path in markdown_images :
515- new_file = ("file" , open (writeup_path .parent / path , mode = "rb" ))
539+ new_file = ("file" , open (solution_path .parent / path , mode = "rb" ))
516540 file_payload = {
517541 "type" : "solution" ,
518542 "solution_id" : solution_id ,
@@ -527,7 +551,7 @@ def _create_solution(self):
527551
528552 # Process snippet includes (--8<-- "filename")
529553 for full_match , filename in snippet_includes :
530- snippet_file_path = writeup_path .parent / filename
554+ snippet_file_path = solution_path .parent / filename
531555 if snippet_file_path .exists ():
532556 with snippet_file_path .open ("r" ) as snippet_file :
533557 snippet_content = snippet_file .read ()
@@ -536,15 +560,7 @@ def _create_solution(self):
536560 else :
537561 log .warning (f"Snippet file not found: { filename } " )
538562
539- # # Log found images for debugging
540- # if markdown_images:
541- # print(f"Found {len(markdown_images)} markdown images in writeup")
542- # if html_images:
543- # log.debug(f"Found {len(html_images)} HTML images in writeup")
544-
545- solution_payload_patch = {
546- "content" : content
547- }
563+ solution_payload_patch = {"content" : content }
548564 r = self .api .patch (f"/api/v1/solutions/{ solution_id } " , json = solution_payload_patch )
549565 r .raise_for_status ()
550566
@@ -990,6 +1006,10 @@ def create(self, ignore: tuple[str] = ()) -> None:
9901006 if "next" not in ignore :
9911007 self ._set_next (_next )
9921008
1009+ # Add solution
1010+ if "solution" not in ignore :
1011+ self ._create_solution ()
1012+
9931013 # Bring back the challenge if it's supposed to be visible
9941014 # Either explicitly, or by assuming the default value (possibly because the state is ignored)
9951015 if challenge .get ("state" , "visible" ) == "visible" or "state" in ignore :
@@ -1060,6 +1080,35 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool:
10601080 f"Challenge file '{ challenge_file } ' specified, but not found at { challenge_file_path } "
10611081 )
10621082
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+ if "visibility" in solution :
1094+ issues ["fields" ].append ("The solution object no longer supports visibility. Use state instead." )
1095+ solution_state = solution .get ("state" , "hidden" )
1096+
1097+ if type (solution_state ) != str or solution_state not in ["hidden" , "visible" , "solved" ]:
1098+ issues ["fields" ].append ("The solution state must be one of: hidden, visible, solved" )
1099+
1100+ else :
1101+ issues ["fields" ].append ("The solution field must be a string path or an object with path and state" )
1102+
1103+ if type (solution_file ) != str or not solution_file :
1104+ issues ["fields" ].append ("The solution object must define a non-empty string path field" )
1105+ else :
1106+ solution_file_path = self .challenge_directory / solution_file
1107+ if solution_file_path .is_file () is False :
1108+ issues ["files" ].append (
1109+ f"Solution file '{ solution_file } ' specified, but not found at { solution_file_path } "
1110+ )
1111+
10631112 # Check that files don't have a flag in them
10641113 for challenge_file in files :
10651114 challenge_file_path = self .challenge_directory / challenge_file
0 commit comments