diff --git a/ctf-diary-generator/generator.py b/ctf-diary-generator/generator.py index b386f47..fdc7b0a 100644 --- a/ctf-diary-generator/generator.py +++ b/ctf-diary-generator/generator.py @@ -1,206 +1,207 @@ import yaml, os, re from datetime import datetime from markdown import markdown from bs4 import BeautifulSoup, Comment #also requires pymdown-extensions #note to self: to convert existing ctf diaries, check whether `\n([0-9a-zA-Z]+)` (or `^((?:[a-zA-Z0-9] ?)+)\n- ` for some messier writeups) looks like a chall name, then replace with `\n### $1` #remove all first level -s that i use for paragraph into 2 \ns #push all ```s to the left without spaces #TODO aggregate stats (total ctfs, total solves, solve and co-solve amt, category solves breakdown, avg chall per ctf?, avg points?, avg solve count??) #exclude organized events CWD = os.path.dirname(os.path.realpath(__file__)) alphanum = re.compile(r'[^A-Za-z0-9\- ]+') merge_space = re.compile(r'\s+') remove_formatting = lambda str: merge_space.sub('-', alphanum.sub('', str).strip()).lower() #for name formats objects = {} comments = {} #read special events with open(f'{CWD}/ctf-diary/special.yml', encoding='utf-8') as s: objects.update(yaml.load(s, Loader=yaml.Loader)) #read all ctfs in record skipped_cmts = [] for file in os.listdir(f'{CWD}/ctf-diary/ctfs'): if file.endswith('.yml'): name = file[:-4] #read metadata into objects with open(f'{CWD}/ctf-diary/ctfs/{file}', encoding='utf-8') as s: objects.update({name: yaml.load(s, Loader=yaml.Loader)}) #read comments; split according to headers ('### ') which should only be used by chall names, where the - is the start of comments #the 3 ### should be good enough to differentiate headers from code comments, we will do sanity check when we actually map the comments anyway try: with open(f'{CWD}/ctf-diary/ctfs/comments/{name}.md', encoding='utf-8') as c: assert c.read(4) == '### ' #the first line should already have a challenge, also discard the header comments.update({name: {remove_formatting((sp:=v.split('\n', 1))[0]): sp[1] for v in c.read().split('\n### ')}}) except Exception as e: skipped_cmts.append(name) if 'challenges' in objects[name] and not all('writeup-url' in chall and chall['writeup-url'] for chall in objects[name]['challenges']): #only print if ctf should have comments print(f'Cannot read comments for {file} ({type(e).__name__}), skipping...') #date is already datetime.date (thanks pyyaml) objects = dict(sorted(objects.items(), key=lambda item: item[1]['date'])) #add year objects year = None templist = [] for k, v in objects.items(): if year != v['date'].year: year = v['date'].year if templist: #ensure templist is not empty before inserting a year (since that always happen) #no need to str(year) since .format nudge it into string anyway templist.append((year, {'style': 'timeline-year', 'content': year, 'date': datetime.fromisoformat(f'{year}-01-01')})) templist.append((k,v)) #apparently printing dicts autosorts it for you but iterating is fine templist.reverse() objects = dict(templist) with open(f'{CWD}/templates/ctf.html', encoding='utf-8') as cf: ctf = cf.read() with open(f'{CWD}/templates/special.html', encoding='utf-8') as sf: special = sf.read() with open(f'{CWD}/templates/diary.html', encoding='utf-8') as df: diary = df.read() #generation helpers added = [] def get_comment(chall, ctf): name = remove_formatting(next(iter(chall.values()))) if 'writeup-url' in chall and chall['writeup-url']: return f'href="{chall["writeup-url"]}"' elif ctf in comments and name in comments[ctf]: added.append(f'{ctf}-{name}') return f'data-comment="{ctf}-{name}"' else: if ctf not in skipped_cmts: #only print if the file is read, otherwise its just redundant print(f"{ctf}-{name} has no comments, name mismatch?") return '' ranks = {1: 'first', 2: 'second', 3: 'third'} def get_ordinal(n): if n < 0: return '
N/A
' else: formatted = f"{n}{'th' if 11 <= (n % 100) <= 13 else ['th', 'st', 'nd', 'rd', 'th'][min(n % 10, 4)]}" if n <= 3: return '
' + formatted + '
' else: return '
' + formatted + '
' special_field_names = { 'name': 'Challenge ', 'writeup-url': 'Full writeup', #special fields that are not actually fields 'first-blood': '', 'writeup-prize': '', } chall_decor = {'first-blood': '🩸', 'writeup-prize': '👑'} special_fields = { #for now instead of bolding first item in row, just bold these 2 since i dont think ill need another name for the challenges anyway #we are still assuming the name is in the first column though - commenting breaks if not 'name': lambda v,c: f'{v} {" ".join([v for k, v in chall_decor.items() if k in c])}', 'challenge-written': lambda v,_: f'{v}', 'writeup-url': lambda v,_: '' + ('✔️' if v else '❌') + '', 'solve-status': lambda v,_: '' + {'solved': '✔️', 'co-solved': '🤝', 'sniped': '💤', 'unsolved': '👀'}[v] + '', #special fields that are not actually fields 'first-blood': lambda v,_: '', 'writeup-prize': lambda v,_: '', } #generate the page following the template formats html = diary.format( ctfs="".join([ #special special.format(style=v['style'], content=v['content']) if 'content' in v else #actual ctfs ctf.format( + id=k, style='timeline-organized' if v['organizer'] else '', #class="headings" to have same style but not clickable if url is null, otherwise link #also link is non w3c compliant hack around interactive elements inside buttons; who complies anyway :) url=f'class="nav-link" onclick="window.location=\'{v["url"]}\'; event.stopPropagation()"' if v['url'] else 'class="headings"', name=v['name'], #isoformat is the one we want for datetime, but we only need date not time; actual format should be . date=f'', duration=f'{v["duration"]}h', #currently we are hardcoding hours, but we can always parse type=v['type'], team=v['team'], rank='
Organizer
' if v['organizer'] else (('
' + v['rank'] + '
' if isinstance(v['rank'], str) else get_ordinal(v['rank'])) + ('' if v['full-clear'] else '')), #use the first challenge as header definition challengeheader='' + ''.join([ #normal fields that are named as expected so we can just use it as the headers '' + name.replace('-', ' ').capitalize() + '' if name not in special_field_names else #special fields that needs renaming special_field_names[name] for name in v['challenges'][0].keys()]) + '' if 'challenges' in v else "
No specific challenges have been logged; It's all a team effort!
", #allow no challenge specified (e.g. A/D ctfs where its basically fully team effort so no specific challs that i wouldve fully solved) challenges=''.join(['' #assume every chall object follows the same format as the first, or else header mismatches + ''.join([ f'{field}' if name not in special_fields else special_fields[name](field, chall) for name, field in chall.items()]) + '' for chall in v['challenges']]) if 'challenges' in v else '', ) for k, v in objects.items()]) ) #lint output soup = BeautifulSoup(html, 'html.parser') for comment in soup.findAll(text=lambda text:isinstance(text, Comment)): comment.extract() with open('ctf.html', 'w', encoding="utf-8") as out: out.write(str(soup)) #minify #write comments into their respective files if not os.path.exists('ctf'): os.mkdir('ctf') for ctf, challs in comments.items(): for name, cmt in challs.items(): with open(f'ctf/{ctf}-{name}.html', 'w', encoding="utf-8") as out: #out.write(markdown(cmt, extensions=['fenced_code', 'codehilite'], extension_configs={'codehilite': {'noclasses': True}})) out.write(markdown(cmt, extensions=['pymdownx.highlight', 'pymdownx.superfences', 'pymdownx.tilde', 'pymdownx.inlinehilite', 'pymdownx.emoji', 'pymdownx.magiclink'], extension_configs={ 'pymdownx.highlight': { 'guess_lang': 'block', 'pygments_lang_class': True, }, })) #sanity check if we missed any challs in the yml by comparing against added comments if f'{ctf}-{name}' not in added: print(f"{ctf}-{name} has a comment but doesn't exist in {ctf}.yml - missed definition?") \ No newline at end of file diff --git a/ctf-diary-generator/templates/ctf.html b/ctf-diary-generator/templates/ctf.html index ea017fc..9f66a50 100644 --- a/ctf-diary-generator/templates/ctf.html +++ b/ctf-diary-generator/templates/ctf.html @@ -1,32 +1,32 @@ -
+
{rank}
{team}
{challengeheader} {challenges}
\ No newline at end of file