├── .gitignore ├── README.md ├── __init__.py ├── config.py ├── discord-webhook-preview.png ├── requirements.txt └── webhook.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ctfd-discord-webhook-plugin 2 | 3 | Discord webhook plugin for CTFd to announce challenge solves! Can be configured with a custom message, and a limit to only announce the first N solves. 4 | 5 | The webhook are called only when the CTF is started. Don't attempt to test as an admin if the CTF is not started: the webhook will not be triggered. 6 | 7 | Functionality made for UIUCTF 2020 8 | 9 | ## Setup 10 | 1. Clone this repo into a folder in the plugin folder of your CTFd 11 | 2. Create a new Webhook in your discord server 12 | 3. Set the appropriate `DISCORD_WEBHOOK_URL`, `DISCORD_WEBHOOK_LIMIT`, `DISCORD_WEBHOOK_MESSAGE` environment variables or edit the `config.py` file. 13 | 14 | If you are using docker-compose to deploy ctfd, I recommend setting the env variables within your docker-compose.yml file. Run `docker-compose build` and `docker-compose up` to rebuild and relaunch ctfd w/ the plugin included. 15 | 16 | 17 | ![preview](./discord-webhook-preview.png) 18 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from .webhook import load -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | def config(app): 4 | ''' 5 | Discord webhook URL to send data to. Set to None to disable plugin entirely. 6 | ''' 7 | app.config['DISCORD_WEBHOOK_URL'] = environ.get('DISCORD_WEBHOOK_URL') 8 | 9 | ''' 10 | Limit on number of solves for challenge to trigger webhook for. Set to 0 to send a message for every solve. 11 | ''' 12 | app.config['DISCORD_WEBHOOK_LIMIT'] = environ.get('DISCORD_WEBHOOK_LIMIT', '3') 13 | 14 | ''' 15 | Webhook flag submission format string. Valid vars: team, user, solves, fsolves (formatted solves), challenge, category, team_id, user_id, challenge_slug, value 16 | ''' 17 | app.config['DISCORD_WEBHOOK_MESSAGE'] = environ.get('DISCORD_WEBHOOK_MESSAGE', 'Congratulations to team {team} for the {fsolves} solve on challenge {challenge}!') 18 | 19 | ''' 20 | Post webhook message when challenge is changed (published, hidden or updated) 21 | ''' 22 | app.config['DISCORD_WEBHOOK_CHALL'] = environ.get('DISCORD_WEBHOOK_CHALL', True) 23 | 24 | ''' 25 | Post webhook message when challenge is updated (otherwise only published or hidden) 26 | ''' 27 | app.config['DISCORD_WEBHOOK_CHALL_UPDATE'] = environ.get('DISCORD_WEBHOOK_CHALL_UPDATE', False) 28 | 29 | ''' 30 | Post webhook message even if challenge has not yet been published (only relevant when update is enabled) 31 | ''' 32 | app.config['DISCORD_WEBHOOK_CHALL_UNPUBLISHED'] = environ.get('DISCORD_WEBHOOK_CHALL_UNPUBLISHED', False) 33 | 34 | ''' 35 | Webhook challenge change format string. Valid vars: challenge, category, action (published, hidden or updated) 36 | ''' 37 | app.config['DISCORD_WEBHOOK_CHALL_MESSAGE'] = environ.get('DISCORD_WEBHOOK_CHALL_MESSAGE', 'Challenge {challenge} has been {action}!') 38 | 39 | ''' 40 | Turning this on turns your DISCORD_WEBHOOK_CHALL_MESSAGE into a f-string. Values can be accessed with data. 41 | 42 | This allows conditional formatting: e.g. {'FIRST BLOOD' if data.solves == 1 else ''} 43 | ''' 44 | app.config['DISCORD_WEBHOOK_FSTRING'] = environ.get('DISCORD_WEBHOOK_FSTRING', False) -------------------------------------------------------------------------------- /discord-webhook-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sigpwny/ctfd-discord-webhook-plugin/b07630cdb7829a326ce70342ddf359b4130115d0/discord-webhook-preview.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | discord-webhook==0.8.0 2 | -------------------------------------------------------------------------------- /webhook.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | from flask.wrappers import Response 3 | from CTFd.utils.dates import ctftime 4 | from CTFd.models import Challenges, Solves 5 | from CTFd.utils import config as ctfd_config 6 | from CTFd.utils.user import get_current_team, get_current_user 7 | from discord_webhook import DiscordWebhook, DiscordEmbed 8 | from functools import wraps 9 | from .config import config 10 | 11 | import re 12 | from urllib.parse import quote 13 | from types import SimpleNamespace 14 | 15 | ordinal = lambda n: "%d%s" % (n,"tsnrhtdd"[(n//10%10!=1)*(n%10<4)*n%10::4]) 16 | sanreg = re.compile(r'(~|!|@|#|\$|%|\^|&|\*|\(|\)|\_|\+|\`|-|=|\[|\]|;|\'|,|\.|\/|\{|\}|\||:|"|<|>|\?)') 17 | sanitize = lambda m: sanreg.sub(r'\1',m) 18 | 19 | def load(app): 20 | config(app) 21 | TEAMS_MODE = ctfd_config.is_teams_mode() 22 | 23 | if not app.config['DISCORD_WEBHOOK_URL']: 24 | print("No DISCORD_WEBHOOK_URL set! Plugin disabled.") 25 | return 26 | def challenge_attempt_decorator(f): 27 | @wraps(f) 28 | def wrapper(*args, **kwargs): 29 | result = f(*args, **kwargs) 30 | if not ctftime(): 31 | return result 32 | if isinstance(result, Response): 33 | data = result.json 34 | if isinstance(data, dict) and data.get("success") == True and isinstance(data.get("data"), dict) and data.get("data").get("status") == "correct": 35 | if request.content_type != "application/json": 36 | request_data = request.form 37 | else: 38 | request_data = request.get_json() 39 | challenge_id = request_data.get("challenge_id") 40 | challenge = Challenges.query.filter_by(id=challenge_id).first_or_404() 41 | solvers = Solves.query.filter_by(challenge_id=challenge.id) 42 | if TEAMS_MODE: 43 | solvers = solvers.filter(Solves.team.has(hidden=False)) 44 | else: 45 | solvers = solvers.filter(Solves.user.has(hidden=False)) 46 | num_solves = solvers.count() 47 | 48 | limit = app.config["DISCORD_WEBHOOK_LIMIT"] 49 | if int(limit) > 0 and num_solves > int(limit): 50 | return result 51 | webhook = DiscordWebhook(url=app.config['DISCORD_WEBHOOK_URL']) 52 | 53 | user = get_current_user() 54 | team = get_current_team() 55 | 56 | format_args = { 57 | "team": sanitize("" if team is None else team.name), 58 | "user_id": user.id, 59 | "team_id": 0 if team is None else team.id, 60 | "user": sanitize(user.name), 61 | "challenge": sanitize(challenge.name), 62 | "challenge_slug": quote(challenge.name), 63 | "value": challenge.value, 64 | "solves": num_solves, 65 | "fsolves": ordinal(num_solves), 66 | "category": sanitize(challenge.category) 67 | } 68 | 69 | # Add first blood support with a second message 70 | if app.config["DISCORD_WEBHOOK_FSTRING"]: 71 | data = SimpleNamespace(**format_args) 72 | message = eval("f'{}'".format(app.config['DISCORD_WEBHOOK_MESSAGE'].replace("'", '"'))) 73 | else: 74 | message = app.config['DISCORD_WEBHOOK_MESSAGE'].format(**format_args) 75 | embed = DiscordEmbed(description=message) 76 | webhook.add_embed(embed) 77 | webhook.execute() 78 | return result 79 | return wrapper 80 | 81 | def patch_challenge_decorator(f): 82 | @wraps(f) 83 | def wrapper(*args, **kwargs): 84 | if not ctftime(): 85 | return f(*args, **kwargs) 86 | 87 | # Make sure request type is "PATCH" https://docs.ctfd.io/docs/api/redoc#tag/challenges/operation/patch_challenge 88 | if request.method != "PATCH": 89 | return f(*args, **kwargs) 90 | 91 | # Check if feature is disabled 92 | if not app.config['DISCORD_WEBHOOK_CHALL']: 93 | return f(*args, **kwargs) 94 | 95 | # Check if challenge was visible beforehand (check if published/updated) 96 | challenge_id = kwargs.get("challenge_id") 97 | challenge_old = Challenges.query.filter_by(id=challenge_id).first_or_404() 98 | challenge_old_state = challenge_old.state 99 | 100 | # Run original route function 101 | result = f(*args, **kwargs) 102 | 103 | if isinstance(result, Response): 104 | data = result.json 105 | if isinstance(data, dict) and data.get("success") == True and isinstance(data.get("data"), dict): 106 | # For this route, the updated challenge data is returned on success, so we grab it directly: 107 | challenge = data.get("data") 108 | # Check whether challenge was published,hidden or updated 109 | if challenge_old_state != challenge.get("state"): 110 | if challenge.get("state") == "hidden": 111 | action = "hidden" 112 | else: 113 | action = "published" 114 | else: 115 | action = "updated" 116 | 117 | # Make sure the challenge is visible, action is hidden, or override is configured 118 | if not (data.get("data").get("state") == "visible" or action == "hidden" or app.config['DISCORD_WEBHOOK_CHALL_UNPUBLISHED']): 119 | return result 120 | 121 | if action == "updated" and not app.config['DISCORD_WEBHOOK_CHALL_UPDATE']: 122 | return result 123 | 124 | format_args = { 125 | "challenge": sanitize(challenge.get("name")), 126 | "category": sanitize(challenge.get("category")), 127 | "action": sanitize(action) 128 | } 129 | 130 | webhook = DiscordWebhook(url=app.config['DISCORD_WEBHOOK_URL']) 131 | message = app.config['DISCORD_WEBHOOK_CHALL_MESSAGE'].format(**format_args) 132 | embed = DiscordEmbed(description=message) 133 | webhook.add_embed(embed) 134 | webhook.execute() 135 | return result 136 | return wrapper 137 | 138 | app.view_functions['api.challenges_challenge_attempt'] = challenge_attempt_decorator(app.view_functions['api.challenges_challenge_attempt']) 139 | app.view_functions['api.challenges_challenge'] = patch_challenge_decorator(app.view_functions['api.challenges_challenge']) 140 | 141 | --------------------------------------------------------------------------------