@@ -263,6 +263,64 @@ def _validate_files(self):
263263 if not (self .challenge_directory / challenge_file ).exists ():
264264 raise InvalidChallengeFile (f"File { challenge_file } could not be loaded" )
265265
266+ @staticmethod
267+ def _coerce_int (value : Any ) -> Any :
268+ if isinstance (value , int ):
269+ return value
270+
271+ if isinstance (value , str ):
272+ stripped_value = value .strip ()
273+ if stripped_value .isdigit () or (
274+ stripped_value .startswith ("-" ) and stripped_value [1 :].isdigit ()
275+ ):
276+ try :
277+ return int (stripped_value )
278+ except ValueError :
279+ return value
280+
281+ return value
282+
283+ def _extract_container_fields (self , ignore : Tuple [str , ...] = ()) -> Dict [str , Any ]:
284+ container_keys = {
285+ "image" ,
286+ "port" ,
287+ "command" ,
288+ "volumes" ,
289+ "ctype" ,
290+ "initial" ,
291+ "decay" ,
292+ "minimum" ,
293+ }
294+
295+ numeric_keys = {"port" , "initial" , "decay" , "minimum" }
296+
297+ container_payload : Dict [str , Any ] = {}
298+ sources : List [Dict [str , Any ]] = []
299+
300+ type_data = self .get ("type_data" )
301+ if isinstance (type_data , dict ):
302+ sources .append (type_data )
303+
304+ if "extra" not in ignore :
305+ extra = self .get ("extra" )
306+ if isinstance (extra , dict ):
307+ sources .append (extra )
308+
309+ sources .append (self )
310+
311+ for source in sources :
312+ for key in container_keys :
313+ if key not in source or source [key ] in (None , "" ):
314+ continue
315+
316+ value = source [key ]
317+ if key in numeric_keys :
318+ value = self ._coerce_int (value )
319+
320+ container_payload [key ] = value
321+
322+ return container_payload
323+
266324 def _get_initial_challenge_payload (self , ignore : Tuple [str ] = ()) -> Dict :
267325 challenge = self
268326 challenge_payload = {
@@ -275,28 +333,26 @@ def _get_initial_challenge_payload(self, ignore: Tuple[str] = ()) -> Dict:
275333 "state" : "hidden" ,
276334 }
277335
278- # Some challenge types (e.g., dynamic) override value.
279- # We can't send it to CTFd because we don't know the current value
280- if challenge .get ("value" , None ) is not None :
281- # if value is an int as string, cast it
282- if type (challenge ["value" ]) == str and challenge ["value" ].isdigit ():
283- challenge_payload ["value" ] = int (challenge ["value" ])
284-
285- if type (challenge ["value" ] == int ):
286- challenge_payload ["value" ] = challenge ["value" ]
336+ value = challenge .get ("value" , None )
337+ if value is not None :
338+ challenge_payload ["value" ] = self ._coerce_int (value )
287339
288340 if "attempts" not in ignore :
289341 challenge_payload ["max_attempts" ] = challenge .get ("attempts" , 0 )
290342
291343 if "connection_info" not in ignore :
292344 challenge_payload ["connection_info" ] = challenge .get ("connection_info" , None )
293345
294- if "logic" not in ignore :
295- if challenge .get ("logic" ):
296- challenge_payload ["logic" ] = challenge .get ("logic" ) or "any"
346+ if "logic" not in ignore and challenge .get ("logic" ):
347+ challenge_payload ["logic" ] = challenge .get ("logic" ) or "any"
297348
298349 if "extra" not in ignore :
299- challenge_payload = {** challenge_payload , ** challenge .get ("extra" , {})}
350+ extra = challenge .get ("extra" , {})
351+ if isinstance (extra , dict ):
352+ challenge_payload = {** challenge_payload , ** extra }
353+
354+ container_fields = self ._extract_container_fields (ignore = ignore )
355+ challenge_payload .update (container_fields )
300356
301357 return challenge_payload
302358
@@ -354,6 +410,52 @@ def _create_tags(self):
354410 )
355411 r .raise_for_status ()
356412
413+ def _upload_writeup (self ) -> None :
414+ writeup_path = self .challenge_directory / "WRITEUP.md"
415+
416+ if not writeup_path .exists () or not writeup_path .is_file ():
417+ return
418+
419+ try :
420+ content = writeup_path .read_text (encoding = "utf-8" )
421+ except Exception as exc : # pragma: no cover - unexpected IO error
422+ log .warning ("Failed to read write-up for challenge %s: %s" , self .get ("name" ), exc )
423+ return
424+
425+ payload = {"challenge" : self .challenge_id , "content" : content , "publish" : True }
426+
427+ try :
428+ response = self .api .post ("/api/v1/writeups" , json = payload )
429+ except Exception as exc : # pragma: no cover - network error
430+ log .warning (
431+ "Failed to upload write-up for challenge %s: %s" , self .get ("name" , self .challenge_id ), exc
432+ )
433+ return
434+
435+ if not response .ok :
436+ log .warning (
437+ "Failed to upload write-up for challenge %s: (%s) %s" ,
438+ self .get ("name" , self .challenge_id ),
439+ response .status_code ,
440+ response .text ,
441+ )
442+ return
443+
444+ try :
445+ data = response .json ()
446+ except ValueError : # pragma: no cover - unexpected response body
447+ log .warning (
448+ "Failed to parse write-up upload response for challenge %s" , self .get ("name" , self .challenge_id )
449+ )
450+ return
451+
452+ if not data .get ("success" , True ):
453+ log .warning (
454+ "CTFd rejected write-up upload for challenge %s: %s" ,
455+ self .get ("name" , self .challenge_id ),
456+ data ,
457+ )
458+
357459 def _delete_file (self , remote_location : str ):
358460 remote_files = self .api .get ("/api/v1/files?type=challenge" ).json ()["data" ]
359461
@@ -557,6 +659,14 @@ def _normalize_challenge(self, challenge_data: Dict[str, Any]):
557659 "state" ,
558660 "connection_info" ,
559661 "logic" ,
662+ "image" ,
663+ "port" ,
664+ "command" ,
665+ "volumes" ,
666+ "ctype" ,
667+ "initial" ,
668+ "decay" ,
669+ "minimum" ,
560670 ]
561671 for key in copy_keys :
562672 if key in challenge_data :
@@ -568,13 +678,6 @@ def _normalize_challenge(self, challenge_data: Dict[str, Any]):
568678 challenge ["attribution" ] = challenge ["attribution" ].strip ().replace ("\r \n " , "\n " ).replace ("\t " , "" )
569679 challenge ["attempts" ] = challenge_data ["max_attempts" ]
570680
571- for key in ["initial" , "decay" , "minimum" ]:
572- if key in challenge_data :
573- if "extra" not in challenge :
574- challenge ["extra" ] = {}
575-
576- challenge ["extra" ][key ] = challenge_data [key ]
577-
578681 # Add flags
579682 r = self .api .get (f"/api/v1/challenges/{ self .challenge_id } /flags" )
580683 r .raise_for_status ()
@@ -696,6 +799,8 @@ def sync(self, ignore: Tuple[str] = ()) -> None:
696799 click .secho (f"Failed to sync challenge: ({ r .status_code } ) { r .text } " , fg = "red" )
697800 r .raise_for_status ()
698801
802+ self ._upload_writeup ()
803+
699804 # Update flags
700805 if "flags" not in ignore :
701806 self ._delete_existing_flags ()
@@ -824,6 +929,8 @@ def create(self, ignore: Tuple[str] = ()) -> None:
824929
825930 self .challenge_id = r .json ()["data" ]["id" ]
826931
932+ self ._upload_writeup ()
933+
827934 # Create flags
828935 if challenge .get ("flags" ) and "flags" not in ignore :
829936 self ._create_flags ()
0 commit comments